diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/shared/commands | |
parent | Initial commit. (diff) | |
download | firefox-upstream/124.0.1.tar.xz firefox-upstream/124.0.1.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/shared/commands')
191 files changed, 24719 insertions, 0 deletions
diff --git a/devtools/shared/commands/README.md b/devtools/shared/commands/README.md new file mode 100644 index 0000000000..8ed199828d --- /dev/null +++ b/devtools/shared/commands/README.md @@ -0,0 +1,51 @@ +# Commands + +Commands are singletons, which can be easily used by any frontend code. +They are meant to be exposed widely to the frontend so that any code can easily call any of their methods. + +Commands classes expose static methods, which: +* route to the right Front/Actor's method +* handle backward compatibility +* map to many target's actor if needed + +These classes are instantiated once per descriptor +and may have inner state, emit events, fire callbacks,... + +A transient backward compat need, required by Fission refactorings will be to have some code checking a trait, and either: +* call a single method on a parent process actor (like BreakpointListActor.setBreakpoint) +* otherwise, call a method on each target's scoped actor (like ThreadActor.setBreakpoint, that, for each available target) + +Without such layer, we would have to put such code here and there in the frontend code. +This will be harder to remove later, once we get rid of old pre-fission-refactoring codepaths. + +This layer already exists in some panels, but we are using slightly different names and practices: +* Debugger uses "client" (devtools/client/debugger/src/client/) and "commands" (devtools/client/debugger/src/client/firefox/commands.js) + Debugger's commands already bundle the code to dispatch an action to many target's actor. + They also contain some backward compat code. + Today, we pass around a `client` object via thunkArgs, which is mapped to commands.js, + instead we could pass a debugger command object. +* Network Monitor uses "connector" (devtools/client/netmonitor/src/connector) + Connectors also bundles backward compat and dispatch to many target's actor. + Today, we pass the `connector` to all middlewares from configureStore, + we could instead pass the netmonitor command object. +* Web Console has: + * devtools/client/webconsole/actions/input.js:handleHelperResult(), where we have to put some code, which is a duplicate of Netmonitor Connector, + and could be shared via a netmonitor command class. +* Inspector is probably the panel doing the most dispatch to many target's actor. + Codes using getAllInspectorFronts could all be migrated to an inspector command class: + https://searchfox.org/mozilla-central/search?q=symbol:%23getAllInspectorFronts&redirect=false + and simplify a bit the frontend. + It is also one panel, which still register listener to each target's inspector/walker fronts. + Because inspector isn't using resources. + But this work, registering listeners for each target might be done by such layer and translate the many actor's event into a unified one. + +Last, but not least, this layer may allow us to slowly get rid of protocol.js. +Command classes aren't Fronts, nor are they particularly connected to protocol.js. +If we make it so that all the Frontend code using Fronts uses Commands instead, we might more easily get away from protocol.js. + +If you want to create a new command, you can use a bash script to help your bootstrap all basic required files: +``` +$ ./create-command.sh command-file-name CommandName +``` +Where the first argument will be the name used for folder and files, using lower case and dash as separator. +And the second argument will be the class name in code, using camlcase. diff --git a/devtools/shared/commands/commands-factory.js b/devtools/shared/commands/commands-factory.js new file mode 100644 index 0000000000..257f50ce2f --- /dev/null +++ b/devtools/shared/commands/commands-factory.js @@ -0,0 +1,245 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + createCommandsDictionary, +} = require("resource://devtools/shared/commands/index.js"); +const { DevToolsLoader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +loader.lazyRequireGetter( + this, + "DevToolsServer", + "resource://devtools/server/devtools-server.js", + true +); +// eslint-disable-next-line mozilla/reject-some-requires +loader.lazyRequireGetter( + this, + "DevToolsClient", + "resource://devtools/client/devtools-client.js", + true +); + +/** + * Functions for creating Commands for all debuggable contexts. + * + * All methods of this `CommandsFactory` object receive argument to describe to + * which particular context we want to debug. And all returns a new instance of `commands` object. + * Commands are implemented by modules defined in devtools/shared/commands. + */ +exports.CommandsFactory = { + /** + * Create commands for a given local tab. + * + * @param {Tab} tab: A local Firefox tab, running in this process. + * @param {Object} options + * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed, + * a new one will be created. + * @param {DevToolsClient} options.isWebExtension: An optional boolean to flag commands + * that are created for the WebExtension codebase. + * @returns {Object} Commands + */ + async forTab(tab, { client, isWebExtension } = {}) { + if (!client) { + client = await createLocalClient(); + } + + const descriptor = await client.mainRoot.getTab({ tab, isWebExtension }); + descriptor.doNotAttachThreadActor = isWebExtension; + const commands = await createCommandsDictionary(descriptor); + return commands; + }, + + /** + * Chrome mochitest don't have access to any "tab", + * so that the only way to attach to a fake tab is call RootFront.getTab + * without any argument. + */ + async forCurrentTabInChromeMochitest() { + const client = await createLocalClient(); + const descriptor = await client.mainRoot.getTab(); + const commands = await createCommandsDictionary(descriptor); + return commands; + }, + + /** + * Create commands for the main process. + * + * @param {Object} options + * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed, + * a new one will be created. + * @returns {Object} Commands + */ + async forMainProcess({ client } = {}) { + if (!client) { + client = await createLocalClient(); + } + + const descriptor = await client.mainRoot.getMainProcess(); + const commands = await createCommandsDictionary(descriptor); + return commands; + }, + + /** + * Create commands for a given remote tab. + * + * Note that it can also be used for local tab, but isLocalTab attribute + * on commands.descriptorFront will be false. + * + * @param {Number} browserId: Identify which tab we should create commands for. + * @param {Object} options + * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed, + * a new one will be created. + * @returns {Object} Commands + */ + async forRemoteTab(browserId, { client } = {}) { + if (!client) { + client = await createLocalClient(); + } + + const descriptor = await client.mainRoot.getTab({ browserId }); + const commands = await createCommandsDictionary(descriptor); + return commands; + }, + + /** + * Create commands for a given main process worker. + * + * @param {String} id: WorkerDebugger's id, which is a unique ID computed by the platform code. + * These ids are exposed via WorkerDescriptor's id attributes. + * WorkerDescriptors can be retrieved via MainFront.listAllWorkers()/listWorkers(). + * @param {Object} options + * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed, + * a new one will be created. + * @returns {Object} Commands + */ + async forWorker(id, { client } = {}) { + if (!client) { + client = await createLocalClient(); + } + + const descriptor = await client.mainRoot.getWorker(id); + const commands = await createCommandsDictionary(descriptor); + return commands; + }, + + /** + * Create commands for a Web Extension. + * + * @param {String} id The Web Extension ID to debug. + * @param {Object} options + * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed, + * a new one will be created. + * @returns {Object} Commands + */ + async forAddon(id, { client } = {}) { + if (!client) { + client = await createLocalClient(); + } + + const descriptor = await client.mainRoot.getAddon({ id }); + const commands = await createCommandsDictionary(descriptor); + return commands; + }, + + /** + * This method will spawn a special `DevToolsClient` + * which is meant to debug the same Firefox instance + * and especially be able to debug chrome code. + * The chrome code typically runs in the system principal. + * This principal is a singleton which is shared among most Firefox internal codebase + * (JSM, privileged html documents, JS-XPCOM,...) + * In order to be able to debug these script we need to connect to a special DevToolsServer + * that runs in a dedicated and distinct system principal which is different from + * the one shared with the rest of Firefox frontend codebase. + */ + async spawnClientToDebugSystemPrincipal() { + // The Browser console ends up using the debugger in autocomplete. + // Because the debugger can't be running in the same compartment than its debuggee, + // we have to load the server in a dedicated Loader, flagged with + // `freshCompartment`, which will force it to be loaded in another compartment. + // We aren't using `invisibleToDebugger` in order to allow the Browser toolbox to + // debug the Browser console. This is fine as they will spawn distinct Loaders and + // so distinct `DevToolsServer` and actor modules. + const customLoader = new DevToolsLoader({ + freshCompartment: true, + }); + const { DevToolsServer: customDevToolsServer } = customLoader.require( + "resource://devtools/server/devtools-server.js" + ); + + customDevToolsServer.init(); + + // We want all the actors (root, browser and target-scoped) to be registered on the + // DevToolsServer. This is needed so the Browser Console can retrieve: + // - the console actors, which are target-scoped (See Bug 1416105) + // - the screenshotActor, which is browser-scoped (for the `:screenshot` command) + customDevToolsServer.registerAllActors(); + + customDevToolsServer.allowChromeProcess = true; + + const client = new DevToolsClient(customDevToolsServer.connectPipe()); + await client.connect(); + + return client; + }, + + /** + * One method to handle the whole setup sequence to connect to RDP backend for the Browser Console. + * + * This will instantiate a special DevTools module loader for the DevToolsServer. + * Then spawn a DevToolsClient to connect to it. + * Get a Main Process Descriptor from it. + * Finally spawn a commands object for this descriptor. + */ + async forBrowserConsole() { + // The Browser console ends up using the debugger in autocomplete. + // Because the debugger can't be running in the same compartment than its debuggee, + // we have to load the server in a dedicated Loader and so spawn a special client + const client = await this.spawnClientToDebugSystemPrincipal(); + + const descriptor = await client.mainRoot.getMainProcess(); + + descriptor.doNotAttachThreadActor = true; + + // Force fetching the first top level target right away. + await descriptor.getTarget(); + + const commands = await createCommandsDictionary(descriptor); + return commands; + }, +}; + +async function createLocalClient() { + // Make sure the DevTools server is started. + ensureDevToolsServerInitialized(); + + // Create the client and connect it to the local server. + const client = new DevToolsClient(DevToolsServer.connectPipe()); + await client.connect(); + + return client; +} +// Also expose this method for tests which would like to create a client +// without involving commands. This would typically be tests against the Watcher actor +// and requires to prevent having TargetCommand from running. +// Or tests which are covering RootFront or global actor's fronts. +exports.createLocalClientForTests = createLocalClient; + +function ensureDevToolsServerInitialized() { + // Since a remote protocol connection will be made, let's start the + // DevToolsServer here, once and for all tools. + DevToolsServer.init(); + + // Enable all the actors. We may not need all of them and registering + // only root and target might be enough + DevToolsServer.registerAllActors(); + + // Enable being able to get child process actors + // Same, this might not be useful + DevToolsServer.allowChromeProcess = true; +} diff --git a/devtools/shared/commands/create-command.sh b/devtools/shared/commands/create-command.sh new file mode 100755 index 0000000000..1f95f96df6 --- /dev/null +++ b/devtools/shared/commands/create-command.sh @@ -0,0 +1,129 @@ +#!/bin/bash + +# Script to easily create a new command, including: +# - a template for the main command file +# - test folder and test head.js file +# - a template for a first test +# - all necessary build manifests + +if [[ -z $1 || -z $2 ]]; then + echo "$0 expects two arguments:" + echo "$(basename $0) command-file-name CommandName" + echo " 1) The file name for the command, with '-' as separators between words" + echo " This will be the name of the folder" + echo " 2) The command name being caml cased" + echo " This will be used to craft the name of the JavaScript class" + exit +fi + +if [ -e $1 ]; then + echo "$1 already exists. Please use a new folder/command name." +fi + +CMD_FOLDER=$1 +CMD_FILE_NAME=$1-command.js +CMD_NAME=$2Command + +pushd `dirname $0` + +echo "Creating a new command called '$CMD_NAME' in $CMD_FOLDER" + +mkdir $CMD_FOLDER + +cat > $CMD_FOLDER/moz.build <<EOF +# 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( + "$CMD_FILE_NAME", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] +EOF + +cat > $CMD_FOLDER/$CMD_FILE_NAME <<EOF +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * The $CMD_NAME ... + */ +class $CMD_NAME { + constructor({ commands, descriptorFront, watcherFront }) { + this.#commands = commands; + } + #commands = null; + +} + +module.exports = $CMD_NAME; +EOF + +mkdir $CMD_FOLDER/tests +cat > $CMD_FOLDER/tests/browser.toml <<EOF +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "head.js", +] + +[browser_$1.js] +EOF + + +cat > $CMD_FOLDER/tests/head.js <<EOF +* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); +EOF + +CMD_NAME_FIRST_LOWERCASE=${CMD_NAME,} +cat > $CMD_FOLDER/tests/browser_$1.js <<EOF +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the $CMD_NAME + +add_task(async function () { + info("Setup the test page"); + const tab = await addTab("data:text/html;charset=utf-8,Test page"); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const { $CMD_NAME_FIRST_LOWERCASE } = commands; + + // assertions... + + await commands.destroy(); + BrowserTestUtils.removeTab(tab); +}); +EOF + +popd + +echo "" +echo "Command created!" +echo "" +echo "Now:" +echo " - edit moz.build to add '\"$CMD_FOLDER\",' in DIRS (this need to be kept sorted)" +echo " - edit index.js to add '$CMD_NAME_FIRST_LOWERCASE: \"devtools/shared/commands/$CMD_FOLDER/$1-command\"' in Commands dictionary" diff --git a/devtools/shared/commands/index.js b/devtools/shared/commands/index.js new file mode 100644 index 0000000000..a9a35bedf7 --- /dev/null +++ b/devtools/shared/commands/index.js @@ -0,0 +1,135 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// List of all command modules +// (please try to keep the list alphabetically sorted) +/* eslint sort-keys: "error" */ +/* eslint-enable sort-keys */ +const Commands = { + inspectedWindowCommand: + "devtools/shared/commands/inspected-window/inspected-window-command", + inspectorCommand: "devtools/shared/commands/inspector/inspector-command", + networkCommand: "devtools/shared/commands/network/network-command", + objectCommand: "devtools/shared/commands/object/object-command", + resourceCommand: "devtools/shared/commands/resource/resource-command", + rootResourceCommand: + "devtools/shared/commands/root-resource/root-resource-command", + scriptCommand: "devtools/shared/commands/script/script-command", + targetCommand: "devtools/shared/commands/target/target-command", + targetConfigurationCommand: + "devtools/shared/commands/target-configuration/target-configuration-command", + threadConfigurationCommand: + "devtools/shared/commands/thread-configuration/thread-configuration-command", + tracerCommand: "devtools/shared/commands/tracer/tracer-command", +}; +/* eslint-disable sort-keys */ + +/** + * For a given descriptor and its related Targets, already initialized, + * return the dictionary with all command instances. + * This dictionary is lazy and commands will be loaded and instanciated on-demand. + */ +async function createCommandsDictionary(descriptorFront) { + // Bug 1675763: Watcher actor is not available in all situations yet. + let watcherFront; + const supportsWatcher = descriptorFront.traits?.watcher; + if (supportsWatcher) { + watcherFront = await descriptorFront.getWatcher(); + } + const { client } = descriptorFront; + + const allInstantiatedCommands = new Set(); + + const dictionary = { + // Expose both client and descriptor for legacy codebases, or tests. + // But ideally only commands should interact with these two objects + client, + descriptorFront, + watcherFront, + + // Expose for tests + waitForRequestsToSettle() { + return descriptorFront.client.waitForRequestsToSettle(); + }, + + // Boolean flag to know if the DevtoolsClient should be closed + // when this commands happens to be destroyed. + // This is set by: + // * commands-from-url in case we are opening a toolbox + // with a dedicated DevToolsClient (mostly from about:debugging, when the client isn't "cached"). + // * CommandsFactory, when we are connecting to a local tab and expect + // the client, toolbox and descriptor to all follow the same lifecycle. + shouldCloseClient: true, + + /** + * Destroy the commands which will destroy: + * - all inner commands, + * - the related descriptor, + * - the related DevToolsClient (not always) + */ + async destroy() { + descriptorFront.off("descriptor-destroyed", this.destroy); + + // Destroy all inner command modules + for (const command of allInstantiatedCommands) { + if (typeof command.destroy == "function") { + command.destroy(); + } + } + allInstantiatedCommands.clear(); + + // Destroy the descriptor front, and all its children fronts. + // Watcher, targets,... + // + // Note that DescriptorFront.destroy will be null because of Pool.destroy + // when this function is called while the descriptor front itself is being + // destroyed. + if (!descriptorFront.isDestroyed()) { + await descriptorFront.destroy(); + } + + // Close the DevToolsClient. Shutting down the connection + // to the debuggable context and its DevToolsServer. + // + // See shouldCloseClient jsdoc about this condition. + if (this.shouldCloseClient) { + await client.close(); + } + }, + }; + dictionary.destroy = dictionary.destroy.bind(dictionary); + + // Automatically destroy the commands object if the descriptor + // happens to be destroyed. Which means that the debuggable context + // is no longer debuggable. + descriptorFront.on("descriptor-destroyed", dictionary.destroy); + + for (const name in Commands) { + loader.lazyGetter(dictionary, name, () => { + const Constructor = require(Commands[name]); + const command = new Constructor({ + // Commands can use other commands + commands: dictionary, + + // The context to inspect identified by this descriptor + descriptorFront, + + // The front for the Watcher Actor, related to the given descriptor + // This is a key actor to watch for targets and resources and pull global actors running in the parent process + watcherFront, + + // From here, we could pass DevToolsClient, or any useful protocol classes... + // so that we abstract where and how to fetch all necessary interfaces + // and avoid having to know that you might pull the client via descriptorFront.client + }); + allInstantiatedCommands.add(command); + return command; + }); + } + + return dictionary; +} +exports.createCommandsDictionary = createCommandsDictionary; diff --git a/devtools/shared/commands/inspected-window/inspected-window-command.js b/devtools/shared/commands/inspected-window/inspected-window-command.js new file mode 100644 index 0000000000..0d15016ebd --- /dev/null +++ b/devtools/shared/commands/inspected-window/inspected-window-command.js @@ -0,0 +1,145 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + getAdHocFrontOrPrimitiveGrip, + // eslint-disable-next-line mozilla/reject-some-requires +} = require("resource://devtools/client/fronts/object.js"); + +/** + * For now, this class is mostly a wrapper around webExtInspectedWindow actor. + */ +class InspectedWindowCommand { + constructor({ commands }) { + this.commands = commands; + } + + /** + * Return a promise that resolves to the related target actor's front. + * The Web Extension inspected window actor. + * + * @return {Promise<WebExtensionInspectedWindowFront>} + */ + getFront() { + return this.commands.targetCommand.targetFront.getFront( + "webExtensionInspectedWindow" + ); + } + + /** + * Evaluate the provided javascript code in a target window. + * + * @param {Object} webExtensionCallerInfo - The addonId and the url (the addon base url + * or the url of the actual caller filename and lineNumber) used to log useful + * debugging information in the produced error logs and eval stack trace. + * @param {String} expression - The expression to evaluate. + * @param {Object} options - An option object. Check the actor method definition to see + * what properties it can hold (minus the `consoleFront` property which is defined + * below). + * @param {WebConsoleFront} options.consoleFront - An optional webconsole front. When + * set, the result will be either a primitive, a LongStringFront or an + * ObjectFront, and the WebConsoleActor corresponding to the console front will + * be used to generate those, which is needed if we want to handle ObjectFronts + * on the client. + */ + async eval(webExtensionCallerInfo, expression, options = {}) { + const { consoleFront } = options; + + if (consoleFront) { + options.evalResultAsGrip = true; + options.toolboxConsoleActorID = consoleFront.actor; + delete options.consoleFront; + } + + const front = await this.getFront(); + const response = await front.eval( + webExtensionCallerInfo, + expression, + options + ); + + // If no consoleFront was provided, we can directly return the response. + if (!consoleFront) { + return response; + } + + if ( + !response.hasOwnProperty("exceptionInfo") && + !response.hasOwnProperty("valueGrip") + ) { + throw new Error( + "Response does not have `exceptionInfo` or `valueGrip` property" + ); + } + + if (response.exceptionInfo) { + console.error( + response.exceptionInfo.description, + ...(response.exceptionInfo.details || []) + ); + return response; + } + + // On the server, the valueGrip is created from the toolbox webconsole actor. + // If we want since the ObjectFront connection is inherited from the parent front, we + // need to set the console front as the parent front. + return getAdHocFrontOrPrimitiveGrip( + response.valueGrip, + consoleFront || this + ); + } + + /** + * Reload the target tab, optionally bypass cache, customize the userAgent and/or + * inject a script in targeted document or any of its sub-frame. + * + * @param {WebExtensionCallerInfo} callerInfo + * the addonId and the url (the addon base url or the url of the actual caller + * filename and lineNumber) used to log useful debugging information in the + * produced error logs and eval stack trace. + * @param {Object} options + * @param {boolean|undefined} options.ignoreCache + * Enable/disable the cache bypass headers. + * @param {string|undefined} options.injectedScript + * Evaluate the provided javascript code in the top level and every sub-frame + * created during the page reload, before any other script in the page has been + * executed. + * @param {string|undefined} options.userAgent + * Customize the userAgent during the page reload. + * @returns {Promise} A promise that resolves once the page is done loading when userAgent + * or injectedScript option are passed. If those options are not provided, the + * Promise will resolve after the reload was initiated. + */ + async reload(callerInfo, options = {}) { + if (this._reloadPending) { + return null; + } + + this._reloadPending = true; + + try { + // We always want to update the target configuration to set the user agent if one is + // passed, or to reset a potential existing override if userAgent isn't defined. + await this.commands.targetConfigurationCommand.updateConfiguration({ + customUserAgent: options.userAgent, + }); + + const front = await this.getFront(); + const result = await front.reload(callerInfo, options); + this._reloadPending = false; + + return result; + } catch (e) { + this._reloadPending = false; + console.error(e); + return Promise.reject({ + message: "An unexpected error occurred", + }); + } + } +} + +module.exports = InspectedWindowCommand; diff --git a/devtools/shared/commands/inspected-window/moz.build b/devtools/shared/commands/inspected-window/moz.build new file mode 100644 index 0000000000..69e72048f8 --- /dev/null +++ b/devtools/shared/commands/inspected-window/moz.build @@ -0,0 +1,10 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "inspected-window-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/inspected-window/tests/browser.toml b/devtools/shared/commands/inspected-window/tests/browser.toml new file mode 100644 index 0000000000..4c700ff7fe --- /dev/null +++ b/devtools/shared/commands/inspected-window/tests/browser.toml @@ -0,0 +1,20 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", + "!/devtools/client/shared/test/highlighter-test-actor.js", + "head.js", + "inspectedwindow-reload-target.sjs", +] +prefs = [ + # restrictedDomains must be set as early as possible, before the first use of + # the preference. browser_webextension_inspected_window_access.js relies on + # this pref to be set. We cannot use "prefs =" at the individual file, because + # another test in this manifest may already have resulted in browser startup. + "extensions.webextensions.restrictedDomains=test2.example.com" +] + +["browser_webextension_inspected_window.js"] +["browser_webextension_inspected_window_access.js"] diff --git a/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window.js b/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window.js new file mode 100644 index 0000000000..bf2b752e4d --- /dev/null +++ b/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window.js @@ -0,0 +1,523 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_RELOAD_URL = `${URL_ROOT_SSL}/inspectedwindow-reload-target.sjs`; + +async function setup(pageUrl) { + // Disable bfcache for Fission for now. + // If Fission is disabled, the pref is no-op. + await SpecialPowers.pushPrefEnv({ + set: [["fission.bfcacheInParent", false]], + }); + + const extension = ExtensionTestUtils.loadExtension({ + background() { + // This is just an empty extension used to ensure that the caller extension uuid + // actually exists. + }, + }); + + await extension.startup(); + + const fakeExtCallerInfo = { + url: WebExtensionPolicy.getByID(extension.id).getURL( + "fake-caller-script.js" + ), + lineNumber: 1, + addonId: extension.id, + }; + + const tab = await addTab(pageUrl); + + const commands = await CommandsFactory.forTab(tab, { isWebExtension: true }); + await commands.targetCommand.startListening(); + + const webConsoleFront = await commands.targetCommand.targetFront.getFront( + "console" + ); + + return { + webConsoleFront, + commands, + extension, + fakeExtCallerInfo, + }; +} + +async function teardown({ commands, extension }) { + await commands.destroy(); + gBrowser.removeCurrentTab(); + await extension.unload(); +} + +function waitForNextTabNavigated(commands) { + const target = commands.targetCommand.targetFront; + return new Promise(resolve => { + target.on("tabNavigated", function tabNavigatedListener(pkt) { + if (pkt.state == "stop" && !pkt.isFrameSwitching) { + target.off("tabNavigated", tabNavigatedListener); + resolve(); + } + }); + }); +} + +// Script used as the injectedScript option in the inspectedWindow.reload tests. +function injectedScript() { + if (!window.pageScriptExecutedFirst) { + window.addEventListener( + "DOMContentLoaded", + function () { + if (document.querySelector("pre")) { + document.querySelector("pre").textContent = + "injected script executed first"; + } + }, + { once: true } + ); + } +} + +// Script evaluated in the target tab, to collect the results of injectedScript +// evaluation in the inspectedWindow.reload tests. +function collectEvalResults() { + const results = []; + let iframeDoc = document; + + while (iframeDoc) { + if (iframeDoc.querySelector("pre")) { + results.push(iframeDoc.querySelector("pre").textContent); + } + const iframe = iframeDoc.querySelector("iframe"); + iframeDoc = iframe ? iframe.contentDocument : null; + } + return JSON.stringify(results); +} + +add_task(async function test_successfull_inspectedWindowEval_result() { + const { commands, extension, fakeExtCallerInfo } = await setup(URL_ROOT_SSL); + + const result = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + "window.location", + {} + ); + + ok(result.value, "Got a result from inspectedWindow eval"); + is( + result.value.href, + URL_ROOT_SSL, + "Got the expected window.location.href property value" + ); + is( + result.value.protocol, + "https:", + "Got the expected window.location.protocol property value" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_successfull_inspectedWindowEval_resultAsGrip() { + const { commands, extension, fakeExtCallerInfo, webConsoleFront } = + await setup(URL_ROOT_SSL); + + let result = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + "window", + { + evalResultAsGrip: true, + toolboxConsoleActorID: webConsoleFront.actor, + } + ); + + ok(result.valueGrip, "Got a result from inspectedWindow eval"); + ok(result.valueGrip.actor, "Got a object actor as expected"); + is(result.valueGrip.type, "object", "Got a value grip of type object"); + is( + result.valueGrip.class, + "Window", + "Got a value grip which is instanceof Location" + ); + + // Test invalid evalResultAsGrip request. + result = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + "window", + { + evalResultAsGrip: true, + } + ); + + ok( + !result.value && !result.valueGrip, + "Got a null result from the invalid inspectedWindow eval call" + ); + ok( + result.exceptionInfo.isError, + "Got an API Error result from inspectedWindow eval" + ); + ok( + !result.exceptionInfo.isException, + "An error isException is false as expected" + ); + is( + result.exceptionInfo.code, + "E_PROTOCOLERROR", + "Got the expected 'code' property in the error result" + ); + is( + result.exceptionInfo.description, + "Inspector protocol error: %s - %s", + "Got the expected 'description' property in the error result" + ); + is( + result.exceptionInfo.details.length, + 2, + "The 'details' array property should contains 1 element" + ); + is( + result.exceptionInfo.details[0], + "Unexpected invalid sidebar panel expression request", + "Got the expected content in the error results's details" + ); + is( + result.exceptionInfo.details[1], + "missing toolboxConsoleActorID", + "Got the expected content in the error results's details" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_error_inspectedWindowEval_result() { + const { commands, extension, fakeExtCallerInfo } = await setup(URL_ROOT_SSL); + + const result = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + "window", + {} + ); + + ok(!result.value, "Got a null result from inspectedWindow eval"); + ok( + result.exceptionInfo.isError, + "Got an API Error result from inspectedWindow eval" + ); + ok( + !result.exceptionInfo.isException, + "An error isException is false as expected" + ); + is( + result.exceptionInfo.code, + "E_PROTOCOLERROR", + "Got the expected 'code' property in the error result" + ); + is( + result.exceptionInfo.description, + "Inspector protocol error: %s", + "Got the expected 'description' property in the error result" + ); + is( + result.exceptionInfo.details.length, + 1, + "The 'details' array property should contains 1 element" + ); + ok( + result.exceptionInfo.details[0].includes("cyclic object value"), + "Got the expected content in the error results's details" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_exception_inspectedWindowEval_result() { + const { commands, extension, fakeExtCallerInfo } = await setup(URL_ROOT_SSL); + + const result = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + "throw Error('fake eval error');", + {} + ); + + ok(result.exceptionInfo.isException, "Got an exception as expected"); + ok(!result.value, "Got an undefined eval value"); + ok(!result.exceptionInfo.isError, "An exception should not be isError=true"); + ok( + result.exceptionInfo.value.includes("Error: fake eval error"), + "Got the expected exception message" + ); + + const expectedCallerInfo = `called from ${fakeExtCallerInfo.url}:${fakeExtCallerInfo.lineNumber}`; + ok( + result.exceptionInfo.value.includes(expectedCallerInfo), + "Got the expected caller info in the exception message" + ); + + const expectedStack = `eval code:1:7`; + ok( + result.exceptionInfo.value.includes(expectedStack), + "Got the expected stack trace in the exception message" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_exception_inspectedWindowReload() { + const { commands, extension, fakeExtCallerInfo } = await setup( + `${TEST_RELOAD_URL}?test=cache` + ); + + // Test reload with bypassCache=false. + + const waitForNoBypassCacheReload = waitForNextTabNavigated(commands); + const reloadResult = await commands.inspectedWindowCommand.reload( + fakeExtCallerInfo, + { + ignoreCache: false, + } + ); + + ok( + !reloadResult, + "Got the expected undefined result from inspectedWindow reload" + ); + + await waitForNoBypassCacheReload; + + const noBypassCacheEval = await commands.scriptCommand.execute( + "document.body.textContent" + ); + + is( + noBypassCacheEval.result, + "empty cache headers", + "Got the expected result with reload forceBypassCache=false" + ); + + // Test reload with bypassCache=true. + + const waitForForceBypassCacheReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, { + ignoreCache: true, + }); + + await waitForForceBypassCacheReload; + + const forceBypassCacheEval = await commands.scriptCommand.execute( + "document.body.textContent" + ); + + is( + forceBypassCacheEval.result, + "no-cache:no-cache", + "Got the expected result with reload forceBypassCache=true" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_exception_inspectedWindowReload_customUserAgent() { + const { commands, extension, fakeExtCallerInfo } = await setup( + `${TEST_RELOAD_URL}?test=user-agent` + ); + + // Test reload with custom userAgent. + + const waitForCustomUserAgentReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, { + userAgent: "Customized User Agent", + }); + + await waitForCustomUserAgentReload; + + const customUserAgentEval = await commands.scriptCommand.execute( + "document.body.textContent" + ); + + is( + customUserAgentEval.result, + "Customized User Agent", + "Got the expected result on reload with a customized userAgent" + ); + + // Test reload with no custom userAgent. + + const waitForNoCustomUserAgentReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {}); + + await waitForNoCustomUserAgentReload; + + const noCustomUserAgentEval = await commands.scriptCommand.execute( + "document.body.textContent" + ); + + is( + noCustomUserAgentEval.result, + window.navigator.userAgent, + "Got the expected result with reload without a customized userAgent" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_exception_inspectedWindowReload_injectedScript() { + const { commands, extension, fakeExtCallerInfo } = await setup( + `${TEST_RELOAD_URL}?test=injected-script&frames=3` + ); + + // Test reload with an injectedScript. + + const waitForInjectedScriptReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, { + injectedScript: `new ${injectedScript}`, + }); + await waitForInjectedScriptReload; + + const injectedScriptEval = await commands.scriptCommand.execute( + `(${collectEvalResults})()` + ); + + const expectedResult = new Array(5).fill("injected script executed first"); + + SimpleTest.isDeeply( + JSON.parse(injectedScriptEval.result), + expectedResult, + "Got the expected result on reload with an injected script" + ); + + // Test reload without an injectedScript. + + const waitForNoInjectedScriptReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {}); + await waitForNoInjectedScriptReload; + + const noInjectedScriptEval = await commands.scriptCommand.execute( + `(${collectEvalResults})()` + ); + + const newExpectedResult = new Array(5).fill("injected script NOT executed"); + + SimpleTest.isDeeply( + JSON.parse(noInjectedScriptEval.result), + newExpectedResult, + "Got the expected result on reload with no injected script" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_exception_inspectedWindowReload_multiple_calls() { + const { commands, extension, fakeExtCallerInfo } = await setup( + `${TEST_RELOAD_URL}?test=user-agent` + ); + + // Test reload with custom userAgent three times (and then + // check that only the first one has affected the page reload. + + const waitForCustomUserAgentReload = waitForNextTabNavigated(commands); + + commands.inspectedWindowCommand.reload(fakeExtCallerInfo, { + userAgent: "Customized User Agent 1", + }); + commands.inspectedWindowCommand.reload(fakeExtCallerInfo, { + userAgent: "Customized User Agent 2", + }); + + await waitForCustomUserAgentReload; + + const customUserAgentEval = await commands.scriptCommand.execute( + "document.body.textContent" + ); + + is( + customUserAgentEval.result, + "Customized User Agent 1", + "Got the expected result on reload with a customized userAgent" + ); + + // Test reload with no custom userAgent. + + const waitForNoCustomUserAgentReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {}); + + await waitForNoCustomUserAgentReload; + + const noCustomUserAgentEval = await commands.scriptCommand.execute( + "document.body.textContent" + ); + + is( + noCustomUserAgentEval.result, + window.navigator.userAgent, + "Got the expected result with reload without a customized userAgent" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_exception_inspectedWindowReload_stopped() { + const { commands, extension, fakeExtCallerInfo } = await setup( + `${TEST_RELOAD_URL}?test=injected-script&frames=3` + ); + + // Test reload on a page that calls window.stop() immediately during the page loading + + const waitForPageLoad = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + "window.location += '&stop=windowStop'" + ); + + info("Load a webpage that calls 'window.stop()' while is still loading"); + await waitForPageLoad; + + info("Starting a reload with an injectedScript"); + const waitForInjectedScriptReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, { + injectedScript: `new ${injectedScript}`, + }); + await waitForInjectedScriptReload; + + const injectedScriptEval = await commands.scriptCommand.execute( + `(${collectEvalResults})()` + ); + + // The page should have stopped during the reload and only one injected script + // is expected. + const expectedResult = new Array(1).fill("injected script executed first"); + + SimpleTest.isDeeply( + JSON.parse(injectedScriptEval.result), + expectedResult, + "The injected script has been executed on the 'stopped' page reload" + ); + + // Reload again with no options. + + info("Reload the tab again without any reload options"); + const waitForNoInjectedScriptReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {}); + await waitForNoInjectedScriptReload; + + const noInjectedScriptEval = await commands.scriptCommand.execute( + `(${collectEvalResults})()` + ); + + // The page should have stopped during the reload and no injected script should + // have been executed during this second reload (or it would mean that the previous + // customized reload was still pending and has wrongly affected the second reload) + const newExpectedResult = new Array(1).fill("injected script NOT executed"); + + SimpleTest.isDeeply( + JSON.parse(noInjectedScriptEval.result), + newExpectedResult, + "No injectedScript should have been evaluated during the second reload" + ); + + await teardown({ commands, extension }); +}); + +// TODO: check eval with $0 binding once implemented (Bug 1300590) diff --git a/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window_access.js b/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window_access.js new file mode 100644 index 0000000000..3b32bb0aaa --- /dev/null +++ b/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window_access.js @@ -0,0 +1,315 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function run_inspectedWindow_eval({ tab, codeToEval, extension }) { + const fakeExtCallerInfo = { + url: `moz-extension://${extension.uuid}/another/fake-caller-script.js`, + lineNumber: 1, + addonId: extension.id, + }; + const commands = await CommandsFactory.forTab(tab, { isWebExtension: true }); + await commands.targetCommand.startListening(); + const result = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + codeToEval, + {} + ); + await commands.destroy(); + return result; +} + +async function openAboutBlankTabWithExtensionOrigin(extension) { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + `moz-extension://${extension.uuid}/manifest.json` + ); + const loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await ContentTask.spawn(tab.linkedBrowser, null, () => { + // about:blank inherits the principal when opened from content. + content.wrappedJSObject.location.assign("about:blank"); + }); + await loaded; + // Sanity checks: + is(tab.linkedBrowser.currentURI.spec, "about:blank", "expected tab"); + is( + tab.linkedBrowser.contentPrincipal.originNoSuffix, + `moz-extension://${extension.uuid}`, + "about:blank should be at the extension origin" + ); + return tab; +} + +async function checkEvalResult({ + extension, + description, + url, + createTab = () => BrowserTestUtils.openNewForegroundTab(gBrowser, url), + expectedResult, +}) { + const tab = await createTab(); + is(tab.linkedBrowser.currentURI.spec, url, "Sanity check: tab URL"); + const result = await run_inspectedWindow_eval({ + tab, + codeToEval: "'code executed at ' + location.href", + extension, + }); + BrowserTestUtils.removeTab(tab); + SimpleTest.isDeeply( + result, + expectedResult, + `eval result for devtools.inspectedWindow.eval at ${url} (${description})` + ); +} + +async function checkEvalAllowed({ extension, description, url, createTab }) { + info(`checkEvalAllowed: ${description} (at URL: ${url})`); + await checkEvalResult({ + extension, + description, + url, + createTab, + expectedResult: { value: `code executed at ${url}` }, + }); +} +async function checkEvalDenied({ extension, description, url, createTab }) { + info(`checkEvalDenied: ${description} (at URL: ${url})`); + await checkEvalResult({ + extension, + description, + url, + createTab, + expectedResult: { + exceptionInfo: { + isError: true, + code: "E_PROTOCOLERROR", + details: [ + "This extension is not allowed on the current inspected window origin", + ], + description: "Inspector protocol error: %s", + }, + }, + }); +} + +add_task(async function test_eval_at_http() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + const httpUrl = "http://example.com/"; + + // When running with --use-http3-server, http:-URLs cannot be loaded. + try { + await fetch(httpUrl); + } catch { + info("Skipping test_eval_at_http because http:-URL cannot be loaded"); + return; + } + + const extension = ExtensionTestUtils.loadExtension({}); + await extension.startup(); + + await checkEvalAllowed({ + extension, + description: "http:-URL", + url: httpUrl, + }); + await extension.unload(); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_eval_at_https() { + const extension = ExtensionTestUtils.loadExtension({}); + await extension.startup(); + + const privilegedExtension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + }); + await privilegedExtension.startup(); + + await checkEvalAllowed({ + extension, + description: "https:-URL", + url: "https://example.com/", + }); + + await checkEvalDenied({ + extension, + description: "a restricted domain", + // Domain in extensions.webextensions.restrictedDomains by browser.toml. + url: "https://test2.example.com/", + }); + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.quarantinedDomains.list", "example.com"]], + }); + + await checkEvalDenied({ + extension, + description: "a quarantined domain", + url: "https://example.com/", + }); + + await checkEvalAllowed({ + extension: privilegedExtension, + description: "a quarantined domain", + url: "https://example.com/", + }); + + await SpecialPowers.popPrefEnv(); + + await extension.unload(); + await privilegedExtension.unload(); +}); + +add_task(async function test_eval_at_sandboxed_page() { + const extension = ExtensionTestUtils.loadExtension({}); + await extension.startup(); + + await checkEvalAllowed({ + extension, + description: "page with CSP sandbox", + url: "https://example.com/document-builder.sjs?headers=Content-Security-Policy:sandbox&html=x", + }); + await checkEvalDenied({ + extension, + description: "restricted domain with CSP sandbox", + url: "https://test2.example.com/document-builder.sjs?headers=Content-Security-Policy:sandbox&html=x", + }); + + await extension.unload(); +}); + +add_task(async function test_eval_at_own_extension_origin_allowed() { + const extension = ExtensionTestUtils.loadExtension({ + background() { + // eslint-disable-next-line no-undef + browser.test.sendMessage( + "blob_url", + URL.createObjectURL(new Blob(["blob: here", { type: "text/html" }])) + ); + }, + files: { + "mozext.html": `<!DOCTYPE html>moz-extension: here`, + }, + }); + await extension.startup(); + const blobUrl = await extension.awaitMessage("blob_url"); + + await checkEvalAllowed({ + extension, + description: "moz-extension:-URL from own extension", + url: `moz-extension://${extension.uuid}/mozext.html`, + }); + await checkEvalAllowed({ + extension, + description: "blob:-URL from own extension", + url: blobUrl, + }); + await checkEvalAllowed({ + extension, + description: "about:blank with origin from own extension", + url: "about:blank", + createTab: () => openAboutBlankTabWithExtensionOrigin(extension), + }); + + await extension.unload(); +}); + +add_task(async function test_eval_at_other_extension_denied() { + // The extension for which we simulate devtools_page, chosen as caller of + // devtools.inspectedWindow.eval API calls. + const extension = ExtensionTestUtils.loadExtension({}); + await extension.startup(); + + // The other extension, that |extension| should not be able to access: + const otherExt = ExtensionTestUtils.loadExtension({ + background() { + // eslint-disable-next-line no-undef + browser.test.sendMessage( + "blob_url", + URL.createObjectURL(new Blob(["blob: here", { type: "text/html" }])) + ); + }, + files: { + "mozext.html": `<!DOCTYPE html>moz-extension: here`, + }, + }); + await otherExt.startup(); + const otherExtBlobUrl = await otherExt.awaitMessage("blob_url"); + + await checkEvalDenied({ + extension, + description: "moz-extension:-URL from another extension", + url: `moz-extension://${otherExt.uuid}/mozext.html`, + }); + await checkEvalDenied({ + extension, + description: "blob:-URL from another extension", + url: otherExtBlobUrl, + }); + await checkEvalDenied({ + extension, + description: "about:blank with origin from another extension", + url: "about:blank", + createTab: () => openAboutBlankTabWithExtensionOrigin(otherExt), + }); + + await otherExt.unload(); + await extension.unload(); +}); + +add_task(async function test_eval_at_about() { + const extension = ExtensionTestUtils.loadExtension({}); + await extension.startup(); + await checkEvalAllowed({ + extension, + description: "about:blank (null principal)", + url: "about:blank", + }); + await checkEvalDenied({ + extension, + description: "about:addons (system principal)", + url: "about:addons", + }); + await checkEvalDenied({ + extension, + description: "about:robots (about page)", + url: "about:robots", + }); + await extension.unload(); +}); + +add_task(async function test_eval_at_file() { + // FYI: There is also an equivalent test case with a full end-to-end test at: + // browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_eval_file.js + + const extension = ExtensionTestUtils.loadExtension({}); + await extension.startup(); + + // A dummy file URL that can be loaded in a tab. + const fileUrl = + "file://" + + getTestFilePath("browser_webextension_inspected_window_access.js"); + + // checkEvalAllowed test helper cannot be used, because the file:-URL may + // redirect elsewhere, so the comparison with the full URL fails. + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, fileUrl); + const result = await run_inspectedWindow_eval({ + tab, + codeToEval: "'code executed at ' + location.protocol", + extension, + }); + BrowserTestUtils.removeTab(tab); + SimpleTest.isDeeply( + result, + { value: "code executed at file:" }, + `eval result for devtools.inspectedWindow.eval at ${fileUrl}` + ); + + await extension.unload(); +}); diff --git a/devtools/shared/commands/inspected-window/tests/head.js b/devtools/shared/commands/inspected-window/tests/head.js new file mode 100644 index 0000000000..ce65b3d827 --- /dev/null +++ b/devtools/shared/commands/inspected-window/tests/head.js @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); diff --git a/devtools/shared/commands/inspected-window/tests/inspectedwindow-reload-target.sjs b/devtools/shared/commands/inspected-window/tests/inspectedwindow-reload-target.sjs new file mode 100644 index 0000000000..4e737ad207 --- /dev/null +++ b/devtools/shared/commands/inspected-window/tests/inspectedwindow-reload-target.sjs @@ -0,0 +1,87 @@ +"use strict"; + +function handleRequest(request, response) { + const params = new URLSearchParams(request.queryString); + + switch (params.get("test")) { + case "cache": + handleCacheTestRequest(request, response); + break; + + case "user-agent": + handleUserAgentTestRequest(request, response); + break; + + case "injected-script": + handleInjectedScriptTestRequest(request, response, params); + break; + } +} + +function handleCacheTestRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + + if (request.hasHeader("pragma") && request.hasHeader("cache-control")) { + response.write( + `${request.getHeader("pragma")}:${request.getHeader("cache-control")}` + ); + } else { + response.write("empty cache headers"); + } +} + +function handleUserAgentTestRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + + if (request.hasHeader("user-agent")) { + response.write(request.getHeader("user-agent")); + } else { + response.write("no user agent header"); + } +} + +function handleInjectedScriptTestRequest(request, response, params) { + response.setHeader("Content-Type", "text/html; charset=UTF-8", false); + + const frames = parseInt(params.get("frames"), 10); + let content = ""; + + if (frames > 0) { + // Output an iframe in seamless mode, so that there is an higher chance that in case + // of test failures we get a screenshot where the nested iframes are all visible. + content = `<iframe seamless src="?test=injected-script&frames=${ + frames - 1 + }"></iframe>`; + } else { + // Output an about:srcdoc frame to be sure that inspectedWindow.eval is able to + // evaluate js code into it. + const srcdoc = ` + <pre>injected script NOT executed</pre> + <script>window.pageScriptExecutedFirst = true</script> + `; + content = `<iframe style="height: 30px;" srcdoc="${srcdoc}"></iframe>`; + } + + if (params.get("stop") == "windowStop") { + content = "<script>window.stop();</script>" + content; + } + + response.write(`<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <style> + iframe { width: 100%; height: ${frames * 150}px; } + </style> + </head> + <body> + <h1>IFRAME ${frames}</h1> + <pre>injected script NOT executed</pre> + <script> + window.pageScriptExecutedFirst = true; + </script> + ${content} + </body> + </html> + `); +} diff --git a/devtools/shared/commands/inspector/inspector-command.js b/devtools/shared/commands/inspector/inspector-command.js new file mode 100644 index 0000000000..a8c4edd6c1 --- /dev/null +++ b/devtools/shared/commands/inspector/inspector-command.js @@ -0,0 +1,483 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +loader.lazyRequireGetter( + this, + "getTargetBrowsers", + "resource://devtools/shared/compatibility/compatibility-user-settings.js", + true +); +loader.lazyRequireGetter( + this, + "TARGET_BROWSER_PREF", + "resource://devtools/shared/compatibility/constants.js", + true +); + +class InspectorCommand { + constructor({ commands }) { + this.commands = commands; + } + + #cssDeclarationBlockIssuesQueuedDomRulesDeclarations = []; + #cssDeclarationBlockIssuesPendingTimeoutPromise; + #cssDeclarationBlockIssuesTargetBrowsersPromise; + + /** + * Return the list of all current target's inspector fronts + * + * @return {Promise<Array<InspectorFront>>} + */ + async getAllInspectorFronts() { + return this.commands.targetCommand.getAllFronts( + [this.commands.targetCommand.TYPES.FRAME], + "inspector" + ); + } + + /** + * Search the document for the given string and return all the results. + * + * @param {Object} walkerFront + * @param {String} query + * The string to search for. + * @param {Object} options + * {Boolean} options.reverse - search backwards + * @returns {Array} The list of search results + */ + async walkerSearch(walkerFront, query, options = {}) { + const result = await walkerFront.search(query, options); + return result.list.items(); + } + + /** + * Incrementally search the top-level document and sub frames for a given string. + * Only one result is sent back at a time. Calling the + * method again with the same query will send the next result. + * If a new query which does not match the current one all is reset and new search + * is kicked off. + * + * @param {String} query + * The string / selector searched for + * @param {Object} options + * {Boolean} reverse - determines if the search is done backwards + * @returns {Object} res + * {String} res.type + * {String} res.query - The string / selector searched for + * {Object} res.node - the current node + * {Number} res.resultsIndex - The index of the current node + * {Number} res.resultsLength - The total number of results found. + */ + async findNextNode(query, { reverse } = {}) { + const inspectors = await this.getAllInspectorFronts(); + const nodes = await Promise.all( + inspectors.map(({ walker }) => + this.walkerSearch(walker, query, { reverse }) + ) + ); + const results = nodes.flat(); + + // If the search query changes + if (this._searchQuery !== query) { + this._searchQuery = query; + this._currentIndex = -1; + } + + if (!results.length) { + return null; + } + + this._currentIndex = reverse + ? this._currentIndex - 1 + : this._currentIndex + 1; + + if (this._currentIndex >= results.length) { + this._currentIndex = 0; + } + if (this._currentIndex < 0) { + this._currentIndex = results.length - 1; + } + + return { + node: results[this._currentIndex], + resultsIndex: this._currentIndex, + resultsLength: results.length, + }; + } + + /** + * Returns a list of matching results for CSS selector autocompletion. + * + * @param {String} query + * The selector query being completed + * @param {String} firstPart + * The exact token being completed out of the query + * @param {String} state + * One of "pseudo", "id", "tag", "class", "null" + * @return {Array<string>} suggestions + * The list of suggested CSS selectors + */ + async getSuggestionsForQuery(query, firstPart, state) { + // Get all inspectors where we want suggestions from. + const inspectors = await this.getAllInspectorFronts(); + + const mergedSuggestions = []; + // Get all of the suggestions. + await Promise.all( + inspectors.map(async ({ walker }) => { + const { suggestions } = await walker.getSuggestionsForQuery( + query, + firstPart, + state + ); + for (const [suggestion, count, type] of suggestions) { + // Merge any already existing suggestion with the new one, by incrementing the count + // which is the second element of the array. + const existing = mergedSuggestions.find( + ([s, , t]) => s == suggestion && t == type + ); + if (existing) { + existing[1] += count; + } else { + mergedSuggestions.push([suggestion, count, type]); + } + } + }) + ); + + // Descending sort the list by count, i.e. second element of the arrays + return sortSuggestions(mergedSuggestions); + } + + /** + * Find a nodeFront from an array of selectors. The last item of the array is the selector + * for the element in its owner document, and the previous items are selectors to iframes + * that lead to the frame where the searched node lives in. + * + * For example, with the following markup + * <html> + * <iframe id="level-1" src="…"> + * <iframe id="level-2" src="…"> + * <h1>Waldo</h1> + * </iframe> + * </iframe> + * + * If you want to retrieve the `<h1>` nodeFront, `selectors` would be: + * [ + * "#level-1", + * "#level-2", + * "h1", + * ] + * + * @param {Array} selectors + * An array of CSS selectors to find the target accessible object. + * Several selectors can be needed if the element is nested in frames + * and not directly in the root document. + * @param {Integer} timeoutInMs + * The maximum number of ms the function should run (defaults to 5000). + * If it exceeds this, the returned promise will resolve with `null`. + * @return {Promise<NodeFront|null>} a promise that resolves when the node front is found + * for selection using inspector tools. It resolves with the deepest frame document + * that could be retrieved when the "final" nodeFront couldn't be found in the page. + * It resolves with `null` when the function runs for more than timeoutInMs. + */ + async findNodeFrontFromSelectors(nodeSelectors, timeoutInMs = 5000) { + if ( + !nodeSelectors || + !Array.isArray(nodeSelectors) || + nodeSelectors.length === 0 + ) { + console.warn( + "findNodeFrontFromSelectors expect a non-empty array but got", + nodeSelectors + ); + return null; + } + + const { walker } = await this.commands.targetCommand.targetFront.getFront( + "inspector" + ); + const querySelectors = async nodeFront => { + const selector = nodeSelectors.shift(); + if (!selector) { + return nodeFront; + } + nodeFront = await nodeFront.walkerFront.querySelector( + nodeFront, + selector + ); + // It's possible the containing iframe isn't available by the time + // walkerFront.querySelector is called, which causes the re-selected node to be + // unavailable. There also isn't a way for us to know when all iframes on the page + // have been created after a reload. Because of this, we should should bail here. + if (!nodeFront) { + return null; + } + + if (nodeSelectors.length) { + if (!nodeFront.isShadowHost) { + await this.#waitForFrameLoad(nodeFront); + } + + const { nodes } = await walker.children(nodeFront); + + // If there are remaining selectors to process, they will target a document or a + // document-fragment under the current node. Whether the element is a frame or + // a web component, it can only contain one document/document-fragment, so just + // select the first one available. + nodeFront = nodes.find(node => { + const { nodeType } = node; + return ( + nodeType === Node.DOCUMENT_FRAGMENT_NODE || + nodeType === Node.DOCUMENT_NODE + ); + }); + + // The iframe selector might have matched an element which is not an + // iframe in the new page (or an iframe with no document?). In this + // case, bail out and fallback to the root body element. + if (!nodeFront) { + return null; + } + } + const childrenNodeFront = await querySelectors(nodeFront); + return childrenNodeFront || nodeFront; + }; + const rootNodeFront = await walker.getRootNode(); + + // Since this is only used for re-setting a selection after a page reloads, we can + // put a timeout, in case there's an iframe that would take too much time to load, + // and prevent the markup view to be populated. + const onTimeout = new Promise(res => setTimeout(res, timeoutInMs)).then( + () => null + ); + const onQuerySelectors = querySelectors(rootNodeFront); + return Promise.race([onTimeout, onQuerySelectors]); + } + + /** + * Wait for the given NodeFront child document to be loaded. + * + * @param {NodeFront} A nodeFront representing a frame + */ + async #waitForFrameLoad(nodeFront) { + const domLoadingPromises = []; + + // if the flag isn't true, we don't know for sure if the iframe will be remote + // or not; when the nodeFront was created, the iframe might still have been loading + // and in such case, its associated window can be an initial document. + // Luckily, once EFT is enabled everywhere we can remove this call and only wait + // for the associated target. + if (!nodeFront.useChildTargetToFetchChildren) { + domLoadingPromises.push(nodeFront.waitForFrameLoad()); + } + + const { onResource: onDomInteractiveResource } = + await this.commands.resourceCommand.waitForNextResource( + this.commands.resourceCommand.TYPES.DOCUMENT_EVENT, + { + // We might be in a case where the children document is already loaded (i.e. we + // would already have received the dom-interactive resource), so it's important + // to _not_ ignore existing resource. + predicate: resource => + resource.name == "dom-interactive" && + resource.targetFront !== nodeFront.targetFront && + resource.targetFront.browsingContextID == + nodeFront.browsingContextID, + } + ); + const newTargetResolveValue = Symbol(); + domLoadingPromises.push( + onDomInteractiveResource.then(() => newTargetResolveValue) + ); + + // Here we wait for any promise to resolve first. `waitForFrameLoad` might throw + // (if the iframe does end up being remote), so we don't want to use `Promise.race`. + const loadResult = await Promise.any(domLoadingPromises); + + // The Node may have `useChildTargetToFetchChildren` set to false because the + // child document was still loading when fetching its form. But it may happen that + // the Node ends up being a remote iframe. + // When this happen we will try to call `waitForFrameLoad` which will throw, but + // we will be notified about the new target. + // This is the special edge case we are trying to handle here. + // We want WalkerFront.children to consider this as an iframe with a dedicated target. + if (loadResult == newTargetResolveValue) { + nodeFront._form.useChildTargetToFetchChildren = true; + } + } + + /** + * Get the full array of selectors from the topmost document, going through + * iframes. + * For example, given the following markup: + * + * <html> + * <body> + * <iframe src="..."> + * <html> + * <body> + * <h1 id="sub-document-title">Title of sub document</h1> + * </body> + * </html> + * </iframe> + * </body> + * </html> + * + * If this function is called with the NodeFront for the h1#sub-document-title element, + * it will return something like: ["body > iframe", "#sub-document-title"] + * + * @param {NodeFront} nodeFront: The nodefront to get the selectors for + * @returns {Promise<Array<String>>} A promise that resolves with an array of selectors (strings) + */ + async getNodeFrontSelectorsFromTopDocument(nodeFront) { + const selectors = []; + + let currentNode = nodeFront; + while (currentNode) { + // Get the selector for the node inside its document + const selector = await currentNode.getUniqueSelector(); + selectors.unshift(selector); + + // Retrieve the node's document/shadowRoot nodeFront so we can get its parent + // (so if we're in an iframe, we'll get the <iframe> node front, and if we're in a + // shadow dom document, we'll get the host). + const rootNode = currentNode.getOwnerRootNodeFront(); + currentNode = rootNode?.parentOrHost(); + } + + return selectors; + } + + #updateTargetBrowsersCache = async () => { + this.#cssDeclarationBlockIssuesTargetBrowsersPromise = getTargetBrowsers(); + }; + + /** + * Get compatibility issues for given domRule declarations + * + * @param {Array<Object>} domRuleDeclarations + * @param {string} domRuleDeclarations[].name: Declaration name + * @param {string} domRuleDeclarations[].value: Declaration value + * @returns {Promise<Array<Object>>} + */ + async getCSSDeclarationBlockIssues(domRuleDeclarations) { + const resultIndex = + this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations.length; + this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations.push( + domRuleDeclarations + ); + + // We're getting the target browsers from RemoteSettings, which can take some time. + // We cache the target browsers to avoid bad performance. + if (!this.#cssDeclarationBlockIssuesTargetBrowsersPromise) { + this.#updateTargetBrowsersCache(); + // Update the target browsers cache when the pref in which we store the compat + // panel settings is updated. + Services.prefs.addObserver( + TARGET_BROWSER_PREF, + this.#updateTargetBrowsersCache + ); + } + + // This can be a hot path if the rules view has a lot of rules displayed. + // Here we wait before sending the RDP request so we can collect all the domRule declarations + // of "concurrent" calls, and only send a single RDP request. + if (!this.#cssDeclarationBlockIssuesPendingTimeoutPromise) { + // Wait before sending the RDP request so all "concurrent" calls can be handle + // in a single RDP request. + this.#cssDeclarationBlockIssuesPendingTimeoutPromise = new Promise( + resolve => { + setTimeout(() => { + this.#cssDeclarationBlockIssuesPendingTimeoutPromise = null; + this.#batchedGetCSSDeclarationBlockIssues().then(data => + resolve(data) + ); + }, 50); + } + ); + } + + const results = await this.#cssDeclarationBlockIssuesPendingTimeoutPromise; + return results?.[resultIndex] || []; + } + + /** + * Get compatibility issues for all queued domRules declarations + * @returns {Promise<Array<Array<Object>>>} + */ + #batchedGetCSSDeclarationBlockIssues = async () => { + const declarations = Array.from( + this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations + ); + this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations = []; + + const { targetFront } = this.commands.targetCommand; + try { + // The server method isn't dependent on the target (it computes the values from the + // declarations we send, which are just property names and values), so we can always + // use the top-level target front. + const inspectorFront = await targetFront.getFront("inspector"); + + const [compatibilityFront, targetBrowsers] = await Promise.all([ + inspectorFront.getCompatibilityFront(), + this.#cssDeclarationBlockIssuesTargetBrowsersPromise, + ]); + + const data = await compatibilityFront.getCSSDeclarationBlockIssues( + declarations, + targetBrowsers + ); + return data; + } catch (e) { + if (this.destroyed || targetFront.isDestroyed()) { + return []; + } + throw e; + } + }; + + destroy() { + Services.prefs.removeObserver( + TARGET_BROWSER_PREF, + this.#updateTargetBrowsersCache + ); + this.destroyed = true; + } +} + +// This is a fork of the server sort: +// https://searchfox.org/mozilla-central/rev/46a67b8656ac12b5c180e47bc4055f713d73983b/devtools/server/actors/inspector/walker.js#1447 +function sortSuggestions(suggestions) { + const sorted = suggestions.sort((a, b) => { + // Computed a sortable string with first the inverted count, then the name + let sortA = 10000 - a[1] + a[0]; + let sortB = 10000 - b[1] + b[0]; + + // Prefixing ids, classes and tags, to group results + const firstA = a[0].substring(0, 1); + const firstB = b[0].substring(0, 1); + + const getSortKeyPrefix = firstLetter => { + if (firstLetter === "#") { + return "2"; + } + if (firstLetter === ".") { + return "1"; + } + return "0"; + }; + + sortA = getSortKeyPrefix(firstA) + sortA; + sortB = getSortKeyPrefix(firstB) + sortB; + + // String compare + return sortA.localeCompare(sortB); + }); + return sorted.slice(0, 25); +} + +module.exports = InspectorCommand; diff --git a/devtools/shared/commands/inspector/moz.build b/devtools/shared/commands/inspector/moz.build new file mode 100644 index 0000000000..1f92b4d9d0 --- /dev/null +++ b/devtools/shared/commands/inspector/moz.build @@ -0,0 +1,10 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "inspector-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/inspector/tests/browser.toml b/devtools/shared/commands/inspector/tests/browser.toml new file mode 100644 index 0000000000..d068db90ac --- /dev/null +++ b/devtools/shared/commands/inspector/tests/browser.toml @@ -0,0 +1,22 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/inspector/test/shared-head.js", + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", + "!/devtools/client/shared/test/highlighter-test-actor.js", + "head.js", +] + +["browser_inspector_command_findNodeFrontFromSelectors.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js"] + +["browser_inspector_command_getSuggestionsForQuery.js"] + +["browser_inspector_command_search.js"] diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_findNodeFrontFromSelectors.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_findNodeFrontFromSelectors.js new file mode 100644 index 0000000000..7991421c8d --- /dev/null +++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_findNodeFrontFromSelectors.js @@ -0,0 +1,140 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async () => { + // Build a simple test page with a remote iframe, using two distinct origins .com and .org + const iframeOrgHtml = encodeURIComponent( + `<h2 id="in-iframe">in org - same origin</h2>` + ); + const iframeComHtml = encodeURIComponent(`<h3>in com - remote</h3>`); + const html = encodeURIComponent( + `<main class="foo bar"> + <button id="child">Click</button> + </main> + <!-- adding delay to both iframe so we can check we handle loading document has expected --> + <iframe id="iframe-org" src="https://example.org/document-builder.sjs?delay=3000&html=${iframeOrgHtml}"></iframe> + <iframe id="iframe-com" src="https://example.com/document-builder.sjs?delay=6000&html=${iframeComHtml}"></iframe>` + ); + const tab = await addTab( + "https://example.org/document-builder.sjs?html=" + html, + { waitForLoad: false } + ); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + info("Check that it returns null when no params are passed"); + let nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors(); + is( + nodeFront, + null, + `findNodeFrontFromSelectors returns null when no param is passed` + ); + + info("Check that it returns null when a string is passed"); + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors( + "body main" + ); + is( + nodeFront, + null, + `findNodeFrontFromSelectors returns null when passed a string` + ); + + info("Check it returns null when an empty array is passed"); + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([]); + is( + nodeFront, + null, + `findNodeFrontFromSelectors returns null when passed an empty array` + ); + + info("Check that passing a selector for a non-matching element returns null"); + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([ + "h1", + ]); + is( + nodeFront, + null, + "findNodeFrontFromSelectors returns null as there's no <h1> element in the page" + ); + + info("Check passing a selector for an element in the top document"); + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([ + "button", + ]); + is( + nodeFront.typeName, + "domnode", + "findNodeFrontFromSelectors returns a nodeFront" + ); + is( + nodeFront.displayName, + "button", + "findNodeFrontFromSelectors returned the appropriate nodeFront" + ); + + info("Check passing a selector for an element in a same origin iframe"); + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([ + "#iframe-org", + "#in-iframe", + ]); + is( + nodeFront.displayName, + "h2", + "findNodeFrontFromSelectors returned the appropriate nodeFront" + ); + + info("Check passing a selector for an element in a cross origin iframe"); + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([ + "#iframe-com", + "h3", + ]); + is( + nodeFront.displayName, + "h3", + "findNodeFrontFromSelectors returned the appropriate nodeFront" + ); + + info( + "Check passing a selector for an non-existing element in an existing iframe" + ); + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([ + "iframe", + "#non-existant-id", + ]); + is( + nodeFront.displayName, + "#document", + "findNodeFrontFromSelectors returned the last matching iframe document if the children selector isn't found" + ); + is( + nodeFront.parentNode().displayName, + "iframe", + "findNodeFrontFromSelectors returned the last matching iframe document if the children selector isn't found" + ); + + info("Check that timeout does work"); + // Reload the page so we'll have the iframe loading (for 3s) and we can check that + // putting a smaller timeout will result in the function returning null. + // we need to wait until it's fully processed to avoid pending promises. + const onNewTargetProcessed = commands.targetCommand.once( + "processed-available-target" + ); + await reloadBrowser({ waitForLoad: false }); + await onNewTargetProcessed; + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors( + ["#iframe-org", "#in-iframe"], + // timeout in ms (smaller than 3s) + 100 + ); + is( + nodeFront, + null, + "findNodeFrontFromSelectors timed out and returned null, as expected" + ); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js new file mode 100644 index 0000000000..3e5abcddd0 --- /dev/null +++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing getNodeFrontSelectorsFromTopDocument + +add_task(async () => { + const html = ` + <html> + <head> + <meta charset="utf8"> + <title>Test</title> + </head> + <body> + <header> + <span>hello</span> + <span>world</span> + </header> + <main> + <iframe src="data:text/html,${encodeURIComponent( + "<html><body><h2 class='frame-child'>foo</h2></body></html>" + )}"></iframe> + </main> + <footer></footer> + + <test-component> + <div slot="slot1" id="el1">content</div> + </test-component> + <script> + 'use strict'; + + customElements.define('test-component', class extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({mode: 'open'}); + shadowRoot.innerHTML = '<slot class="slot-class" name="slot1"></slot>'; + } + }); + </script> + </body> + </html>`; + + const tab = await addTab("data:text/html," + encodeURIComponent(html)); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const walker = ( + await commands.targetCommand.targetFront.getFront("inspector") + ).walker; + + const checkSelectors = (...args) => + checkSelectorsFromTopDocumentForNode(commands, ...args); + + let node = await getNodeFrontInFrames(["meta"], { walker }); + await checkSelectors( + node, + ["head > meta:nth-child(1)"], + "Got expected selectors for the top-level meta node" + ); + + node = await getNodeFrontInFrames(["body"], { walker }); + await checkSelectors( + node, + ["body"], + "Got expected selectors for the top-level body node" + ); + + node = await getNodeFrontInFrames(["header > span"], { walker }); + await checkSelectors( + node, + ["body > header:nth-child(1) > span:nth-child(1)"], + "Got expected selectors for the top-level span node" + ); + + node = await getNodeFrontInFrames(["iframe"], { walker }); + await checkSelectors( + node, + ["body > main:nth-child(2) > iframe:nth-child(1)"], + "Got expected selectors for the iframe node" + ); + + node = await getNodeFrontInFrames(["iframe", "body"], { walker }); + await checkSelectors( + node, + ["body > main:nth-child(2) > iframe:nth-child(1)", "body"], + "Got expected selectors for the iframe body node" + ); + + const hostFront = await getNodeFront("test-component", { walker }); + const { nodes } = await walker.children(hostFront); + const shadowRoot = nodes.find(hostNode => hostNode.isShadowRoot); + node = await walker.querySelector(shadowRoot, ".slot-class"); + + await checkSelectors( + node, + ["body > test-component:nth-child(4)", ".slot-class"], + "Got expected selectors for the shadow dom node" + ); + + await commands.destroy(); +}); + +async function checkSelectorsFromTopDocumentForNode( + commands, + nodeFront, + expectedSelectors, + assertionText +) { + const selectors = + await commands.inspectorCommand.getNodeFrontSelectorsFromTopDocument( + nodeFront + ); + is( + JSON.stringify(selectors), + JSON.stringify(expectedSelectors), + assertionText + ); +} diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js new file mode 100644 index 0000000000..e7b765b1d0 --- /dev/null +++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js @@ -0,0 +1,124 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async () => { + // Build a test page with a remote iframe, using two distinct origins .com and .org + const iframeHtml = encodeURIComponent(`<div id="iframe"></div>`); + const html = encodeURIComponent( + `<div class="foo bar"> + <div id="child"></div> + </div> + <iframe src="https://example.org/document-builder.sjs?html=${iframeHtml}"></iframe>` + ); + const tab = await addTab( + "https://example.com/document-builder.sjs?html=" + html + ); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + info( + "Suggestions for 'di' with tag search, will match the two <div> elements in top document and the one in the iframe" + ); + await assertSuggestion( + commands, + { query: "", firstPart: "di", state: "tag" }, + [ + { + suggestion: "div", + count: 3, // Matches the two <div> in the top document and the one in the iframe + type: "tag", + }, + ] + ); + + info( + "Suggestions for 'ifram' with id search, will only match the <div> within the iframe" + ); + await assertSuggestion( + commands, + { query: "", firstPart: "ifram", state: "id" }, + [ + { + suggestion: "#iframe", + count: 1, + type: "id", + }, + ] + ); + + info( + "Suggestions for 'fo' with tag search, will match the class of the top <div> element" + ); + await assertSuggestion( + commands, + { query: "", firstPart: "fo", state: "tag" }, + [ + { + suggestion: ".foo", + count: 1, + type: "class", + }, + ] + ); + + info( + "Suggestions for classes, based on div elements, will match the two classes of top <div> element" + ); + await assertSuggestion( + commands, + { query: "div", firstPart: "", state: "class" }, + [ + { + suggestion: ".bar", + count: 1, + type: "class", + }, + { + suggestion: ".foo", + count: 1, + type: "class", + }, + ] + ); + + info("Suggestion for non-existent tag names will return no suggestion"); + await assertSuggestion( + commands, + { query: "", firstPart: "marquee", state: "tag" }, + [] + ); + + await commands.destroy(); +}); + +async function assertSuggestion( + commands, + { query, firstPart, state }, + expectedSuggestions +) { + const suggestions = await commands.inspectorCommand.getSuggestionsForQuery( + query, + firstPart, + state + ); + is( + suggestions.length, + expectedSuggestions.length, + "Got the expected number of suggestions" + ); + for (let i = 0; i < expectedSuggestions.length; i++) { + info(` ## Asserting suggestion #${i}:`); + const expectedSuggestion = expectedSuggestions[i]; + const [suggestion, count, type] = suggestions[i]; + is( + suggestion, + expectedSuggestion.suggestion, + "The suggested string is valid" + ); + is(count, expectedSuggestion.count, "The number of matches is valid"); + is(type, expectedSuggestion.type, "The type of match is valid"); + } +} diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_search.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_search.js new file mode 100644 index 0000000000..d7d25d3ce6 --- /dev/null +++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_search.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing basic inspector search + +add_task(async () => { + const html = `<div> + <div> + <p>This is the paragraph node down in the tree</p> + </div> + <div class="child"></div> + <div class="child"></div> + <iframe src="data:text/html,${encodeURIComponent( + "<html><body><div class='frame-child'>foo</div></body></html>" + )}"> + </iframe> + </div>`; + + const tab = await addTab("data:text/html," + encodeURIComponent(html)); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + info("Search using text"); + await searchAndAssert( + commands, + { query: "paragraph", reverse: false }, + { resultsLength: 1, resultsIndex: 0 } + ); + + info("Search using class selector"); + info(" > Get first result "); + await searchAndAssert( + commands, + { query: ".child", reverse: false }, + { resultsLength: 2, resultsIndex: 0 } + ); + + info(" > Get next result "); + await searchAndAssert( + commands, + { query: ".child", reverse: false }, + { resultsLength: 2, resultsIndex: 1 } + ); + + info("Search using el selector with reverse option"); + info(" > Get first result "); + await searchAndAssert( + commands, + { query: "div", reverse: true }, + { resultsLength: 6, resultsIndex: 5 } + ); + + info(" > Get next result "); + await searchAndAssert( + commands, + { query: "div", reverse: true }, + { resultsLength: 6, resultsIndex: 4 } + ); + + info("Search for foo in remote frame"); + await searchAndAssert( + commands, + { query: ".frame-child", reverse: false }, + { resultsLength: 1, resultsIndex: 0 } + ); + + await commands.destroy(); +}); +/** + * Does an inspector search to find the next node and assert the results + * + * @param {Object} commands + * @param {Object} options + * options.query - search query + * options.reverse - search in reverse + * @param {Object} expected + * Holds the expected values + */ +async function searchAndAssert(commands, { query, reverse }, expected) { + const response = await commands.inspectorCommand.findNextNode(query, { + reverse, + }); + + is( + response.resultsLength, + expected.resultsLength, + "Got the expected no of results" + ); + + is( + response.resultsIndex, + expected.resultsIndex, + "Got the expected currently selected node index" + ); +} diff --git a/devtools/shared/commands/inspector/tests/head.js b/devtools/shared/commands/inspector/tests/head.js new file mode 100644 index 0000000000..73d9798446 --- /dev/null +++ b/devtools/shared/commands/inspector/tests/head.js @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); diff --git a/devtools/shared/commands/moz.build b/devtools/shared/commands/moz.build new file mode 100644 index 0000000000..b006ad8dbd --- /dev/null +++ b/devtools/shared/commands/moz.build @@ -0,0 +1,22 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "inspected-window", + "inspector", + "network", + "object", + "resource", + "root-resource", + "script", + "target", + "target-configuration", + "thread-configuration", + "tracer", +] + +DevToolsModules( + "commands-factory.js", + "index.js", +) diff --git a/devtools/shared/commands/network/moz.build b/devtools/shared/commands/network/moz.build new file mode 100644 index 0000000000..9d74abdfa2 --- /dev/null +++ b/devtools/shared/commands/network/moz.build @@ -0,0 +1,10 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "network-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/network/network-command.js b/devtools/shared/commands/network/network-command.js new file mode 100644 index 0000000000..44cdf4e759 --- /dev/null +++ b/devtools/shared/commands/network/network-command.js @@ -0,0 +1,96 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +class NetworkCommand { + /** + * This class helps listen, inspect and control network requests. + * + * @param {DescriptorFront} descriptorFront + * The context to inspect identified by this descriptor. + * @param {WatcherFront} watcherFront + * If available, a reference to the related Watcher Front. + * @param {Object} commands + * The commands object with all interfaces defined from devtools/shared/commands/ + */ + constructor({ descriptorFront, watcherFront, commands }) { + this.commands = commands; + this.descriptorFront = descriptorFront; + this.watcherFront = watcherFront; + } + + /** + * Send a HTTP request data payload + * + * @param {object} data data payload would like to sent to backend + */ + async sendHTTPRequest(data) { + // By default use the top-level target, but we might at some point + // allow using another target. + const networkContentFront = + await this.commands.targetCommand.targetFront.getFront("networkContent"); + const { channelId } = await networkContentFront.sendHTTPRequest(data); + return { channelId }; + } + + /* + * Get the list of blocked URL filters. + * + * A URL filter is a RegExp string so that one filter can match many URLs. + * It can be an absolute URL to match only one precise request: + * http://mozilla.org/index.html + * Or just a string which would match all URL containing this string: + * mozilla + * Or a RegExp to match various types of URLs: + * http://*mozilla.org/*.css + * + * @return {Array} + * List of all currently blocked URL filters. + */ + async getBlockedUrls() { + const networkParentFront = await this.watcherFront.getNetworkParentActor(); + return networkParentFront.getBlockedUrls(); + } + + /** + * Updates the list of blocked URL filters. + * + * @param {Array} urls + * An array of URL filter strings. + * See getBlockedUrls for definition of URL filters. + */ + async setBlockedUrls(urls) { + const networkParentFront = await this.watcherFront.getNetworkParentActor(); + return networkParentFront.setBlockedUrls(urls); + } + + /** + * Block only one additional URL filter + * + * @param {String} url + * URL filter to block. + * See getBlockedUrls for definition of URL filters. + */ + async blockRequestForUrl(url) { + const networkParentFront = await this.watcherFront.getNetworkParentActor(); + return networkParentFront.blockRequest({ url }); + } + + /** + * Stop blocking only one specific URL filter + * + * @param {String} url + * URL filter to unblock. + * See getBlockedUrls for definition of URL filters. + */ + async unblockRequestForUrl(url) { + const networkParentFront = await this.watcherFront.getNetworkParentActor(); + return networkParentFront.unblockRequest({ url }); + } + + destroy() {} +} + +module.exports = NetworkCommand; diff --git a/devtools/shared/commands/network/tests/browser.toml b/devtools/shared/commands/network/tests/browser.toml new file mode 100644 index 0000000000..60b5949bb0 --- /dev/null +++ b/devtools/shared/commands/network/tests/browser.toml @@ -0,0 +1,13 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", + "!/devtools/client/shared/test/highlighter-test-actor.js", + "head.js", +] + +["browser_network_command_request_blocking.js"] + +["browser_network_command_sendHTTPRequest.js"] diff --git a/devtools/shared/commands/network/tests/browser_network_command_request_blocking.js b/devtools/shared/commands/network/tests/browser_network_command_request_blocking.js new file mode 100644 index 0000000000..dd1167fa9b --- /dev/null +++ b/devtools/shared/commands/network/tests/browser_network_command_request_blocking.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the NetworkCommand API around request blocking + +add_task(async function () { + info("Test NetworkCommand request blocking"); + const tab = await addTab("data:text/html,foo"); + const commands = await CommandsFactory.forTab(tab); + const networkCommand = commands.networkCommand; + const resourceCommand = commands.resourceCommand; + + // Usage of request blocking APIs requires to listen to NETWORK_EVENT. + await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable: () => {}, + }); + + let blockedUrls = await networkCommand.getBlockedUrls(); + Assert.deepEqual( + blockedUrls, + [], + "The list of blocked URLs is originaly empty" + ); + + await networkCommand.blockRequestForUrl("https://foo.com"); + blockedUrls = await networkCommand.getBlockedUrls(); + Assert.deepEqual( + blockedUrls, + ["https://foo.com"], + "The freshly added blocked URL is reported as blocked" + ); + + // We pass "url filters" which can be only part of a URL string + await networkCommand.blockRequestForUrl("bar"); + blockedUrls = await networkCommand.getBlockedUrls(); + Assert.deepEqual( + blockedUrls, + ["https://foo.com", "bar"], + "The second blocked URL is also reported as blocked" + ); + + await networkCommand.setBlockedUrls(["https://mozilla.org"]); + blockedUrls = await networkCommand.getBlockedUrls(); + Assert.deepEqual( + blockedUrls, + ["https://mozilla.org"], + "setBlockedUrls replace the whole list of blocked URLs" + ); + + await networkCommand.unblockRequestForUrl("https://mozilla.org"); + blockedUrls = await networkCommand.getBlockedUrls(); + Assert.deepEqual( + blockedUrls, + [], + "The unblocked URL disappear from the list of blocked URLs" + ); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/network/tests/browser_network_command_sendHTTPRequest.js b/devtools/shared/commands/network/tests/browser_network_command_sendHTTPRequest.js new file mode 100644 index 0000000000..1d84a8a668 --- /dev/null +++ b/devtools/shared/commands/network/tests/browser_network_command_sendHTTPRequest.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the NetworkCommand's sendHTTPRequest + +add_task(async function () { + info("Test NetworkCommand.sendHTTPRequest"); + const tab = await addTab("data:text/html,foo"); + const commands = await CommandsFactory.forTab(tab); + + // We have to ensure TargetCommand is initialized to have access to the top level target + // from NetworkCommand.sendHTTPRequest + await commands.targetCommand.startListening(); + + const { networkCommand } = commands; + + const httpServer = createTestHTTPServer(); + const onRequest = new Promise(resolve => { + httpServer.registerPathHandler( + "/http-request.html", + (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("Response body"); + resolve(request); + } + ); + }); + const url = `http://localhost:${httpServer.identity.primaryPort}/http-request.html`; + + info("Call NetworkCommand.sendHTTPRequest"); + const { resourceCommand } = commands; + const { onResource } = await resourceCommand.waitForNextResource( + resourceCommand.TYPES.NETWORK_EVENT + ); + const { channelId } = await networkCommand.sendHTTPRequest({ + url, + method: "POST", + headers: [{ name: "Request", value: "Header" }], + body: "Hello", + cause: { + loadingDocumentUri: "https://example.com", + stacktraceAvailable: true, + type: "xhr", + }, + }); + ok(channelId, "Received a channel id in response"); + const resource = await onResource; + is( + resource.resourceId, + channelId, + "NETWORK_EVENT resource channelId is the same as the one returned by sendHTTPRequest" + ); + + const request = await onRequest; + is(request.method, "POST", "Request method is correct"); + is(request.getHeader("Request"), "Header", "The custom header was passed"); + is(fetchRequestBody(request), "Hello", "The request POST's body is correct"); + + await commands.destroy(); +}); + +const BinaryInputStream = Components.Constructor( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +function fetchRequestBody(request) { + let body = ""; + const bodyStream = new BinaryInputStream(request.bodyInputStream); + let avail = 0; + while ((avail = bodyStream.available()) > 0) { + body += String.fromCharCode.apply(String, bodyStream.readByteArray(avail)); + } + return body; +} diff --git a/devtools/shared/commands/network/tests/head.js b/devtools/shared/commands/network/tests/head.js new file mode 100644 index 0000000000..ce65b3d827 --- /dev/null +++ b/devtools/shared/commands/network/tests/head.js @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); diff --git a/devtools/shared/commands/object/moz.build b/devtools/shared/commands/object/moz.build new file mode 100644 index 0000000000..151750907c --- /dev/null +++ b/devtools/shared/commands/object/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( + "object-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/object/object-command.js b/devtools/shared/commands/object/object-command.js new file mode 100644 index 0000000000..0396b6167a --- /dev/null +++ b/devtools/shared/commands/object/object-command.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"; + +/** + * The ObjectCommand helps inspecting and managing lifecycle + * of all inspected JavaScript objects. + */ +class ObjectCommand { + constructor({ commands, descriptorFront, watcherFront }) { + this.#commands = commands; + } + #commands = null; + + /** + * Release a set of object actors all at once. + * + * @param {Array<ObjectFront>} frontsToRelease + * List of fronts for the object to release. + */ + async releaseObjects(frontsToRelease) { + // @backward-compat { version 123 } A new Objects Manager front has a new "releaseActors" method. + // Only supportsReleaseActors=true codepath can be kept once 123 is the release channel. + const { supportsReleaseActors } = this.#commands.client.mainRoot.traits; + + // First group all object fronts per target + const actorsPerTarget = new Map(); + const promises = []; + for (const frontToRelease of frontsToRelease) { + const { targetFront } = frontToRelease; + // If the front is already destroyed, its target front will be nullified. + if (!targetFront) { + continue; + } + + let actorIDsToRemove = actorsPerTarget.get(targetFront); + if (!actorIDsToRemove) { + actorIDsToRemove = []; + actorsPerTarget.set(targetFront, actorIDsToRemove); + } + if (supportsReleaseActors) { + actorIDsToRemove.push(frontToRelease.actorID); + frontToRelease.destroy(); + } else { + promises.push(frontToRelease.release()); + } + } + + if (supportsReleaseActors) { + // Then release all fronts by bulk per target + for (const [targetFront, actorIDs] of actorsPerTarget) { + const objectsManagerFront = await targetFront.getFront("objects-manager"); + promises.push(objectsManagerFront.releaseObjects(actorIDs)); + } + } + + await Promise.all(promises); + } +} + +module.exports = ObjectCommand; diff --git a/devtools/shared/commands/object/tests/browser.toml b/devtools/shared/commands/object/tests/browser.toml new file mode 100644 index 0000000000..4f1dbe830e --- /dev/null +++ b/devtools/shared/commands/object/tests/browser.toml @@ -0,0 +1,9 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "head.js", +] + +["browser_object.js"] diff --git a/devtools/shared/commands/object/tests/browser_object.js b/devtools/shared/commands/object/tests/browser_object.js new file mode 100644 index 0000000000..9f6d5132d3 --- /dev/null +++ b/devtools/shared/commands/object/tests/browser_object.js @@ -0,0 +1,125 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ObjectCommand + +add_task(async function testObjectRelease() { + const tab = await addTab("data:text/html;charset=utf-8,Test page<script>var foo = { bar: 42 };</script>"); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const { objectCommand } = commands; + + const evaluationResponse = await commands.scriptCommand.execute( + "window.foo" + ); + + // Execute a second time so that the WebConsoleActor set this._lastConsoleInputEvaluation to another value + // and so we prevent freeing `window.foo` + await commands.scriptCommand.execute(""); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + is(content.wrappedJSObject.foo.bar, 42); + const weakRef = Cu.getWeakReference(content.wrappedJSObject.foo); + + // Hold off the weak reference on SpecialPowsers so that it can be accessed in the next SpecialPowers.spawn + SpecialPowers.weakRef = weakRef; + + // Nullify this variable so that it should be freed + // unless the DevTools inspection still hold it in memory + content.wrappedJSObject.foo = null; + + Cu.forceGC(); + Cu.forceCC(); + + ok(SpecialPowers.weakRef.get(), "The 'foo' object can't be freed because of DevTools keeping a reference on it"); + }); + + info("Release the server side actors which are keeping the object in memory"); + const objectFront = evaluationResponse.result; + await commands.objectCommand.releaseObjects([objectFront]); + + ok(objectFront.isDestroyed(), "The passed object front has been destroyed"); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + Cu.forceGC(); + Cu.forceCC(); + return !SpecialPowers.weakRef.get(); + }, "Wait for JS object to be freed", 500); + + ok(!SpecialPowers.weakRef.get(), "The 'foo' object has been freed"); + }); + + await commands.destroy(); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testMultiTargetObjectRelease() { + // This test fails with EFT disabled + if (!isEveryFrameTargetEnabled()) { + return; + } + + const tab = await addTab(`data:text/html;charset=utf-8,Test page<iframe src="data:text/html,bar">/iframe>`); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const [,iframeTarget] = commands.targetCommand.getAllTargets(commands.targetCommand.ALL_TYPES); + is(iframeTarget.url, "data:text/html,bar"); + + const { objectCommand } = commands; + + const evaluationResponse1 = await commands.scriptCommand.execute( + "window" + ); + const evaluationResponse2 = await commands.scriptCommand.execute( + "window", { + selectedTargetFront: iframeTarget, + } + ); + const object1 = evaluationResponse1.result; + const object2 = evaluationResponse2.result; + isnot(object1, object2, "The two window object fronts are different"); + isnot(object1.targetFront, object2.targetFront, "The two window object fronts relates to two distinct targets"); + is(object2.targetFront, iframeTarget, "The second object relates to the iframe target"); + + await commands.objectCommand.releaseObjects([object1, object2]); + ok(object1.isDestroyed(), "The first object front is destroyed"); + ok(object2.isDestroyed(), "The second object front is destroyed"); + + await commands.destroy(); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testWorkerObjectRelease() { + const workerUrl = `data:text/javascript,const foo = {}`; + const tab = await addTab(`data:text/html;charset=utf-8,Test page<script>const worker = new Worker("${workerUrl}")</script>`); + + const commands = await CommandsFactory.forTab(tab); + commands.targetCommand.listenForWorkers = true; + await commands.targetCommand.startListening(); + + const [,workerTarget] = commands.targetCommand.getAllTargets(commands.targetCommand.ALL_TYPES); + is(workerTarget.url, workerUrl); + + const { objectCommand } = commands; + + const evaluationResponse = await commands.scriptCommand.execute( + "foo", { + selectedTargetFront: workerTarget, + } + ); + const object = evaluationResponse.result; + is(object.targetFront, workerTarget, "The 'foo' object relates to the worker target"); + + await commands.objectCommand.releaseObjects([object]); + ok(object.isDestroyed(), "The object front is destroyed"); + + await commands.destroy(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/devtools/shared/commands/object/tests/head.js b/devtools/shared/commands/object/tests/head.js new file mode 100644 index 0000000000..ce65b3d827 --- /dev/null +++ b/devtools/shared/commands/object/tests/head.js @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); diff --git a/devtools/shared/commands/resource/legacy-listeners/console-messages.js b/devtools/shared/commands/resource/legacy-listeners/console-messages.js new file mode 100644 index 0000000000..ae3f81b4df --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/console-messages.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + // Allow the top level target unconditionnally. + // Also allow frame, but only in content toolbox, i.e. still ignore them in + // the context of the browser toolbox as we inspect messages via the process + // targets + const listenForFrames = targetCommand.descriptorFront.isTabDescriptor; + + // Allow workers when messages aren't dispatched to the main thread. + const listenForWorkers = + !targetCommand.rootFront.traits + .workerConsoleApiMessagesDispatchedToMainThread; + + const acceptTarget = + targetFront.isTopLevel || + targetFront.targetType === targetCommand.TYPES.PROCESS || + (targetFront.targetType === targetCommand.TYPES.FRAME && listenForFrames) || + (targetFront.targetType === targetCommand.TYPES.WORKER && listenForWorkers); + + if (!acceptTarget) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + if (webConsoleFront.isDestroyed()) { + return; + } + + // Request notifying about new messages + await webConsoleFront.startListeners(["ConsoleAPI"]); + + // Fetch already existing messages + // /!\ The actor implementation requires to call startListeners(ConsoleAPI) first /!\ + const { messages } = await webConsoleFront.getCachedMessages(["ConsoleAPI"]); + + for (const message of messages) { + message.resourceType = ResourceCommand.TYPES.CONSOLE_MESSAGE; + } + onAvailable(messages); + + // Forward new message events + webConsoleFront.on("consoleAPICall", message => { + // Ignore console messages that are cloned from the content process + // (they aren't relevant to toolboxes still using legacy listeners) + if (message.clonedFromContentProcess) { + return; + } + + message.resourceType = ResourceCommand.TYPES.CONSOLE_MESSAGE; + onAvailable([message]); + }); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/css-changes.js b/devtools/shared/commands/resource/legacy-listeners/css-changes.js new file mode 100644 index 0000000000..e9f3e17075 --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/css-changes.js @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetFront, onAvailable }) { + if (!targetFront.hasActor("changes")) { + return; + } + + const changesFront = await targetFront.getFront("changes"); + + // Get all changes collected up to this point by the ChangesActor on the server, + // then fire each change as "add-change". + const changes = await changesFront.allChanges(); + await onAvailable(changes.map(change => toResource(change))); + + changesFront.on("add-change", change => onAvailable([toResource(change)])); +}; + +function toResource(change) { + return Object.assign(change, { + resourceType: ResourceCommand.TYPES.CSS_CHANGE, + }); +} diff --git a/devtools/shared/commands/resource/legacy-listeners/error-messages.js b/devtools/shared/commands/resource/legacy-listeners/error-messages.js new file mode 100644 index 0000000000..5ba898c917 --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/error-messages.js @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); +const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js"); + +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + // Allow the top level target unconditionnally. + // Also allow frame, but only in content toolbox, i.e. still ignore them in + // the context of the browser toolbox as we inspect messages via the process + // targets + // Also ignore workers as they are not supported yet. (see bug 1592584) + const listenForFrames = targetCommand.descriptorFront.isTabDescriptor; + const isAllowed = + targetFront.isTopLevel || + targetFront.targetType === targetCommand.TYPES.PROCESS || + (targetFront.targetType === targetCommand.TYPES.FRAME && listenForFrames); + + if (!isAllowed) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + if (webConsoleFront.isDestroyed()) { + return; + } + + // Request notifying about new messages. Here the "PageError" type start listening for + // both actual PageErrors (emitted as "pageError" events) as well as LogMessages ( + // emitted as "logMessage" events). This function only set up the listener on the + // webConsoleFront for "pageError". + await webConsoleFront.startListeners(["PageError"]); + + // Fetch already existing messages + // /!\ The actor implementation requires to call startListeners("PageError") first /!\ + let { messages } = await webConsoleFront.getCachedMessages(["PageError"]); + + // On server < v79, we're also getting CSS Messages that we need to filter out. + messages = messages.filter( + message => message.pageError.category !== MESSAGE_CATEGORY.CSS_PARSER + ); + + messages.forEach(message => { + message.resourceType = ResourceCommand.TYPES.ERROR_MESSAGE; + }); + // Cached messages don't have the same shape as live messages, + // so we need to transform them. + onAvailable(messages); + + webConsoleFront.on("pageError", message => { + // On server < v79, we're getting CSS Messages that we need to filter out. + if (message.pageError.category === MESSAGE_CATEGORY.CSS_PARSER) { + return; + } + + message.resourceType = ResourceCommand.TYPES.ERROR_MESSAGE; + onAvailable([message]); + }); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/moz.build b/devtools/shared/commands/resource/legacy-listeners/moz.build new file mode 100644 index 0000000000..6ffb469891 --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/moz.build @@ -0,0 +1,14 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "console-messages.js", + "css-changes.js", + "error-messages.js", + "platform-messages.js", + "reflow.js", + "root-node.js", + "source.js", + "thread-states.js", +) diff --git a/devtools/shared/commands/resource/legacy-listeners/platform-messages.js b/devtools/shared/commands/resource/legacy-listeners/platform-messages.js new file mode 100644 index 0000000000..729696275e --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/platform-messages.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + // Only allow the top level target and processes. + // Frames can be ignored as logMessage are never sent to them anyway. + // Also ignore workers as they are not supported yet. (see bug 1592584) + const isAllowed = + targetFront.isTopLevel || + targetFront.targetType === targetCommand.TYPES.PROCESS; + if (!isAllowed) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + if (webConsoleFront.isDestroyed()) { + return; + } + + // Request notifying about new messages. Here the "PageError" type start listening for + // both actual PageErrors (emitted as "pageError" events) as well as LogMessages ( + // emitted as "logMessage" events). This function only set up the listener on the + // webConsoleFront for "logMessage". + await webConsoleFront.startListeners(["PageError"]); + + // Fetch already existing messages + // /!\ The actor implementation requires to call startListeners("PageError") first /!\ + const { messages } = await webConsoleFront.getCachedMessages(["LogMessage"]); + + for (const message of messages) { + message.resourceType = ResourceCommand.TYPES.PLATFORM_MESSAGE; + } + onAvailable(messages); + + webConsoleFront.on("logMessage", message => { + message.resourceType = ResourceCommand.TYPES.PLATFORM_MESSAGE; + onAvailable([message]); + }); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/reflow.js b/devtools/shared/commands/resource/legacy-listeners/reflow.js new file mode 100644 index 0000000000..63802f510d --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/reflow.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetFront, onAvailable }) { + if (!targetFront.getTrait("isBrowsingContext")) { + // The reflows only work with BrowsingContext targets + return; + } + const reflowFront = await targetFront.getFront("reflow"); + reflowFront.on("reflows", reflows => + onAvailable([ + { + resourceType: ResourceCommand.TYPES.REFLOW, + reflows, + }, + ]) + ); + await reflowFront.start(); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/root-node.js b/devtools/shared/commands/resource/legacy-listeners/root-node.js new file mode 100644 index 0000000000..6fa2bcbf22 --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/root-node.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetFront, onAvailable, onDestroyed }) { + // XXX: When watching root node for a non top-level target, this will also + // ensure the inspector & walker fronts for the target are initialized. + // This also implies that we call reparentRemoteFrame on the new walker, which + // will create the link between the parent frame NodeFront and the inner + // document NodeFront. + // + // This is not something that will work when the resource is moved to the + // server. When it becomes a server side resource, a RootNode would be emitted + // directly by the target actor. + // + // This probably means that the root node resource cannot remain a NodeFront. + // It should not be a front and the client should be responsible for + // retrieving the corresponding NodeFront. + // + // The other thing that we are missing with this patch is that we should only + // create inspector & walker fronts (and call reparentRemoteFrame) when we get + // a RootNode which is directly under an iframe node which is currently + // visible and tracked in the markup view. + // + // For instance, with the following markup: + // html + // body + // div + // iframe + // remote doc + // + // If the markup view only sees nodes down to `div`, then the client is not + // currently tracking the nodeFront for the `iframe`, and getting a new root + // node for the remote document should NOT force the iframe to be tracked on + // on the client. + // + // When we get a RootNode resource, we will need a way to check this before + // initializing & reparenting the walker. + // + if (!targetFront.getTrait("isBrowsingContext")) { + // The root-node resource is only available on browsing-context targets. + return; + } + + const inspectorFront = await targetFront.getFront("inspector"); + inspectorFront.walker.on("root-available", node => { + node.resourceType = ResourceCommand.TYPES.ROOT_NODE; + return onAvailable([node]); + }); + + inspectorFront.walker.on("root-destroyed", node => { + node.resourceType = ResourceCommand.TYPES.ROOT_NODE; + return onDestroyed([node]); + }); + + await inspectorFront.walker.watchRootNode(); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/source.js b/devtools/shared/commands/resource/legacy-listeners/source.js new file mode 100644 index 0000000000..45ee62f70f --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/source.js @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +/** + * Emit SOURCE resources, which represents a Javascript source and has the following attributes set on "available": + * + * - introductionType {null|String}: A string indicating how this source code was introduced into the system. + * This will typically be set to "scriptElement", "eval", ... + * But this may have many other values: + * https://searchfox.org/mozilla-central/rev/ac142717cc067d875e83e4b1316f004f6e063a46/dom/script/ScriptLoader.cpp#2628-2639 + * https://searchfox.org/mozilla-central/search?q=symbol:_ZN2JS14CompileOptions19setIntroductionTypeEPKc&redirect=false + * https://searchfox.org/mozilla-central/rev/ac142717cc067d875e83e4b1316f004f6e063a46/devtools/server/actors/source.js#160-169 + * - sourceMapBaseURL {String}: Base URL where to look for a source map. + * This isn't the source map URL. + * - sourceMapURL {null|String}: URL of the source map, if there is one. + * - url {null|String}: URL of the source, if it relates to a particular URL. + * Evaled sources won't have any related URL. + * - isBlackBoxed {Boolean}: Specifying whether the source actor's 'black-boxed' flag is set. + * - extensionName {null|String}: If the source comes from an add-on, the add-on name. + */ +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + const isBrowserToolbox = + targetCommand.descriptorFront.isBrowserProcessDescriptor; + const isNonTopLevelFrameTarget = + !targetFront.isTopLevel && + targetFront.targetType === targetCommand.TYPES.FRAME; + + if (isBrowserToolbox && isNonTopLevelFrameTarget) { + // In the BrowserToolbox, non-top-level frame targets are already + // debugged via content-process targets. + return; + } + + const threadFront = await targetFront.getFront("thread"); + + // Use a list of all notified SourceFront as we don't have a newSource event for all sources + // but we sometime get sources notified both via newSource event *and* sources() method... + // We store actor ID instead of SourceFront as it appears that multiple SourceFront for the same + // actor are created... + const sourcesActorIDCache = new Set(); + + // Forward new sources (but also existing ones, see next comment) + threadFront.on("newSource", ({ source }) => { + if (sourcesActorIDCache.has(source.actor)) { + return; + } + sourcesActorIDCache.add(source.actor); + // source is a SourceActor's form, add the resourceType attribute on it + source.resourceType = ResourceCommand.TYPES.SOURCE; + onAvailable([source]); + }); + + // Forward already existing sources + // Note that calling `sources()` will end up emitting `newSource` event for all existing sources. + // But not in some cases, for example, when the thread is already paused. + // (And yes, it means that already existing sources can be transfered twice over the wire) + // + // Also, browser_ext_devtools_inspectedWindow_targetSwitch.js creates many top level targets, + // for which the SourceMapURLService will fetch sources. But these targets are destroyed while + // the test is running and when they are, we purge all pending requests, including this one. + // So ignore any error if this request failed on destruction. + let sources; + try { + sources = await threadFront.sources(); + } catch (e) { + if (threadFront.isDestroyed()) { + return; + } + throw e; + } + + // Note that `sources()` doesn't encapsulate SourceFront into a `source` attribute + // while `newSource` event does. + sources = sources.filter(source => { + return !sourcesActorIDCache.has(source.actor); + }); + for (const source of sources) { + sourcesActorIDCache.add(source.actor); + // source is a SourceActor's form, add the resourceType attribute on it + source.resourceType = ResourceCommand.TYPES.SOURCE; + } + onAvailable(sources); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/thread-states.js b/devtools/shared/commands/resource/legacy-listeners/thread-states.js new file mode 100644 index 0000000000..42c922072a --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/thread-states.js @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + const isBrowserToolbox = + targetCommand.descriptorFront.isBrowserProcessDescriptor; + const isNonTopLevelFrameTarget = + !targetFront.isTopLevel && + targetFront.targetType === targetCommand.TYPES.FRAME; + + if (isBrowserToolbox && isNonTopLevelFrameTarget) { + // In the BrowserToolbox, non-top-level frame targets are already + // debugged via content-process targets. + return; + } + + // Wait for the thread actor to be attached, otherwise getFront(thread) will throw for worker targets + // This is because worker target are still kind of descriptors and are only resolved into real target + // after being attached. And the thread actor ID is only retrieved and available after being attached. + await targetFront.onThreadAttached; + + if (targetFront.isDestroyed()) { + return; + } + const threadFront = await targetFront.getFront("thread"); + + let isInterrupted = false; + const onPausedPacket = packet => { + // If paused by an explicit interrupt, which are generated by the + // slow script dialog and internal events such as setting + // breakpoints, ignore the event. + const { why } = packet; + if (why.type === "interrupted" && !why.onNext) { + isInterrupted = true; + return; + } + + // Ignore attached events because they are not useful to the user. + if (why.type == "alreadyPaused" || why.type == "attached") { + return; + } + + onAvailable([ + { + resourceType: ResourceCommand.TYPES.THREAD_STATE, + state: "paused", + why, + frame: packet.frame, + }, + ]); + }; + threadFront.on("paused", onPausedPacket); + + threadFront.on("resumed", packet => { + // NOTE: the client suppresses resumed events while interrupted + // to prevent unintentional behavior. + // see [client docs](devtools/client/debugger/src/client/README.md#interrupted) for more information. + if (isInterrupted) { + isInterrupted = false; + return; + } + + onAvailable([ + { + resourceType: ResourceCommand.TYPES.THREAD_STATE, + state: "resumed", + }, + ]); + }); + + // Notify about already paused thread + const pausedPacket = threadFront.getLastPausePacket(); + if (pausedPacket) { + onPausedPacket(pausedPacket); + } +}; diff --git a/devtools/shared/commands/resource/moz.build b/devtools/shared/commands/resource/moz.build new file mode 100644 index 0000000000..190589df4b --- /dev/null +++ b/devtools/shared/commands/resource/moz.build @@ -0,0 +1,15 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "legacy-listeners", + "transformers", +] + +DevToolsModules( + "resource-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/resource/resource-command.js b/devtools/shared/commands/resource/resource-command.js new file mode 100644 index 0000000000..c45dc6a584 --- /dev/null +++ b/devtools/shared/commands/resource/resource-command.js @@ -0,0 +1,1367 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { throttle } = require("resource://devtools/shared/throttle.js"); + +let gLastResourceId = 0; + +function cacheKey(resourceType, resourceId) { + return `${resourceType}:${resourceId}`; +} + +class ResourceCommand { + /** + * This class helps retrieving existing and listening to resources. + * A resource is something that: + * - the target you are debugging exposes + * - can be created as early as the process/worker/page starts loading + * - can already exist, or will be created later on + * - doesn't require any user data to be fetched, only a type/category + * + * @param object commands + * The commands object with all interfaces defined from devtools/shared/commands/ + */ + constructor({ commands }) { + this.targetCommand = commands.targetCommand; + + // Public attribute set by tests to disable throttling + this.throttlingDisabled = false; + + this._onTargetAvailable = this._onTargetAvailable.bind(this); + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + + this._onResourceAvailable = this._onResourceAvailable.bind(this); + this._onResourceDestroyed = this._onResourceDestroyed.bind(this); + + // Array of all the currently registered watchers, which contains object with attributes: + // - {String} resources: list of all resource watched by this one watcher + // - {Function} onAvailable: watcher's function to call when a new resource is available + // - {Function} onUpdated: watcher's function to call when a resource has been updated + // - {Function} onDestroyed: watcher's function to call when a resource is destroyed + this._watchers = []; + + // Set of watchers currently going through watchResources, only used to handle + // early calls to unwatchResources. Using a Set instead of an array for easier + // delete operations. + this._pendingWatchers = new Set(); + + // Caches for all resources by the order that the resource was taken. + this._cache = new Map(); + this._listenedResources = new Set(); + + // WeakMap used to avoid starting a legacy listener twice for the same + // target + resource-type pair. Legacy listener creation can be subject to + // race conditions. + // Maps a target front to an array of resource types. + this._existingLegacyListeners = new WeakMap(); + this._processingExistingResources = new Set(); + + // List of targetFront event listener unregistration functions keyed by target front. + // These are called when unwatching resources, so if a consumer starts watching resources again, + // we don't have listeners registered twice. + this._offTargetFrontListeners = new Map(); + + this._notifyWatchers = this._notifyWatchers.bind(this); + this._throttledNotifyWatchers = throttle(this._notifyWatchers, 100); + } + + get watcherFront() { + return this.targetCommand.watcherFront; + } + + addResourceToCache(resource) { + const { resourceId, resourceType } = resource; + this._cache.set(cacheKey(resourceType, resourceId), resource); + } + + /** + * Clear all the resources related to specifed resource types. + * Should also trigger clearing of the caches that exists on the related + * serverside resource watchers. + * + * @param {Array:string} resourceTypes + * A list of all the resource types whose + * resources shouled be cleared. + */ + async clearResources(resourceTypes) { + if (!Array.isArray(resourceTypes)) { + throw new Error("clearResources expects a list of resources types"); + } + // Clear the cached resources of the type. + for (const [key, resource] of this._cache) { + if (resourceTypes.includes(resource.resourceType)) { + // NOTE: To anyone paranoid like me, yes it is okay to delete from a Map while iterating it. + this._cache.delete(key); + } + } + + const resourcesToClear = resourceTypes.filter(resourceType => + this.hasResourceCommandSupport(resourceType) + ); + if (resourcesToClear.length) { + this.watcherFront.clearResources(resourcesToClear); + } + } + /** + * Return all specified resources cached in this watcher. + * + * @param {String} resourceType + * @return {Array} resources cached in this watcher + */ + getAllResources(resourceType) { + const result = []; + for (const resource of this._cache.values()) { + if (resource.resourceType === resourceType) { + result.push(resource); + } + } + return result; + } + + /** + * Return the specified resource cached in this watcher. + * + * @param {String} resourceType + * @param {String} resourceId + * @return {Object} resource cached in this watcher + */ + getResourceById(resourceType, resourceId) { + return this._cache.get(cacheKey(resourceType, resourceId)); + } + + /** + * Request to start retrieving all already existing instances of given + * type of resources and also start watching for the one to be created after. + * + * @param {Array:string} resources + * List of all resources which should be fetched and observed. + * @param {Object} options + * - {Function} onAvailable: This attribute is mandatory. + * Function which will be called with an array of resources + * each time resource(s) are created. + * A second dictionary argument with `areExistingResources` boolean + * attribute helps knowing if that's live resources, or some coming + * from ResourceCommand cache. + * - {Function} onUpdated: This attribute is optional. + * Function which will be called with an array of updates resources + * each time resource(s) are updated. + * These resources were previously notified via onAvailable. + * - {Function} onDestroyed: This attribute is optional. + * Function which will be called with an array of deleted resources + * each time resource(s) are destroyed. + * - {boolean} ignoreExistingResources: + * This attribute is optional. Default value is false. + * If set to true, onAvailable won't be called with + * existing resources. + */ + async watchResources(resources, options) { + const { + onAvailable, + onUpdated, + onDestroyed, + ignoreExistingResources = false, + } = options; + + if (typeof onAvailable !== "function") { + throw new Error( + "ResourceCommand.watchResources expects an onAvailable function as argument" + ); + } + + for (const type of resources) { + if (!this._isValidResourceType(type)) { + throw new Error( + `ResourceCommand.watchResources invoked with an unknown type: "${type}"` + ); + } + } + + // Pending watchers are used in unwatchResources to remove watchers which + // are not fully registered yet. Store `onAvailable` which is the unique key + // for a watcher, as well as the resources array, so that unwatchResources + // can update the array if we stop watching a specific resource. + const pendingWatcher = { + resources, + onAvailable, + }; + this._pendingWatchers.add(pendingWatcher); + + // Bug 1675763: Watcher actor is not available in all situations yet. + if (!this._listenerRegistered && this.watcherFront) { + this._listenerRegistered = true; + // Resources watched from the parent process will be emitted on the Watcher Actor. + // So that we also have to listen for this event on it, in addition to all targets. + this.watcherFront.on( + "resource-available-form", + this._onResourceAvailable.bind(this, { + watcherFront: this.watcherFront, + }) + ); + this.watcherFront.on( + "resource-updated-form", + this._onResourceUpdated.bind(this, { watcherFront: this.watcherFront }) + ); + this.watcherFront.on( + "resource-destroyed-form", + this._onResourceDestroyed.bind(this, { + watcherFront: this.watcherFront, + }) + ); + } + + const promises = []; + for (const resource of resources) { + promises.push(this._startListening(resource)); + } + await Promise.all(promises); + + // The resource cache is immediately filled when receiving the sources, but they are + // emitted with a delay due to throttling. Since the cache can contain resources that + // will soon be emitted, we have to flush it before adding the new listeners. + // Otherwise _forwardExistingResources might emit resources that will also be emitted by + // the next `_notifyWatchers` call done when calling `_startListening`, which will pull the + // "already existing" resources. + this._notifyWatchers(); + + // Update the _pendingWatchers set before adding the watcher to _watchers. + this._pendingWatchers.delete(pendingWatcher); + + // If unwatchResources was called in the meantime, use pendingWatcher's + // resources to get the updated list of watched resources. + const watchedResources = pendingWatcher.resources; + + // If no resource needs to be watched anymore, do not add an empty watcher + // to _watchers, and do not notify about cached resources. + if (!watchedResources.length) { + return; + } + + // Register the watcher just after calling _startListening in order to avoid it being called + // for already existing resources, which will optionally be notified via _forwardExistingResources + this._watchers.push({ + resources: watchedResources, + onAvailable, + onUpdated, + onDestroyed, + pendingEvents: [], + }); + + if (!ignoreExistingResources) { + await this._forwardExistingResources(watchedResources, onAvailable); + } + } + + /** + * Stop watching for given type of resources. + * See `watchResources` for the arguments as both methods receive the same. + * Note that `onUpdated` and `onDestroyed` attributes of `options` aren't used here. + * Only `onAvailable` attribute is looked up and we unregister all the other registered callbacks + * when a matching available callback is found. + */ + unwatchResources(resources, options) { + const { onAvailable } = options; + + if (typeof onAvailable !== "function") { + throw new Error( + "ResourceCommand.unwatchResources expects an onAvailable function as argument" + ); + } + + for (const type of resources) { + if (!this._isValidResourceType(type)) { + throw new Error( + `ResourceCommand.unwatchResources invoked with an unknown type: "${type}"` + ); + } + } + + // Unregister the callbacks from the watchers registries. + // Check _watchers for the fully initialized watchers, as well as + // `_pendingWatchers` for new watchers still being created by `watchResources` + const allWatchers = [...this._watchers, ...this._pendingWatchers]; + for (const watcherEntry of allWatchers) { + // onAvailable is the only mandatory argument which ends up being used to match + // the right watcher entry. + if (watcherEntry.onAvailable == onAvailable) { + // Remove all resources that we stop watching. We may still watch for some others. + watcherEntry.resources = watcherEntry.resources.filter(resourceType => { + return !resources.includes(resourceType); + }); + } + } + this._watchers = this._watchers.filter(entry => { + // Remove entries entirely if it isn't watching for any resource type + return !!entry.resources.length; + }); + + // Stop listening to all resources for which we removed the last watcher + for (const resource of resources) { + const isResourceWatched = allWatchers.some(watcherEntry => + watcherEntry.resources.includes(resource) + ); + + // Also check in _listenedResources as we may call unwatchResources + // for resources that we haven't started watching for. + if (!isResourceWatched && this._listenedResources.has(resource)) { + this._stopListening(resource); + } + } + + // Stop watching for targets if we removed the last listener. + if (this._listenedResources.size == 0) { + this._unwatchAllTargets(); + } + } + + /** + * Wait for a single resource of the provided resourceType. + * + * @param {String} resourceType + * One of ResourceCommand.TYPES, type of the expected resource. + * @param {Object} additional options + * - {Boolean} ignoreExistingResources: ignore existing resources or not. + * - {Function} predicate: if provided, will wait until a resource makes + * predicate(resource) return true. + * @return {Promise<Object>} + * Return a promise which resolves once we fully settle the resource listener. + * You should await for its resolution before doing the action which may fire + * your resource. + * This promise will expose an object with `onResource` attribute, + * itself being a promise, which will resolve once a matching resource is received. + */ + async waitForNextResource( + resourceType, + { ignoreExistingResources = false, predicate } = {} + ) { + // If no predicate was provided, convert to boolean to avoid resolving for + // empty `resources` arrays. + predicate = predicate || (resource => !!resource); + + let resolve; + const promise = new Promise(r => (resolve = r)); + const onAvailable = async resources => { + const matchingResource = resources.find(resource => predicate(resource)); + if (matchingResource) { + this.unwatchResources([resourceType], { onAvailable }); + resolve(matchingResource); + } + }; + + await this.watchResources([resourceType], { + ignoreExistingResources, + onAvailable, + }); + return { onResource: promise }; + } + + /** + * Check if there are any watchers for the specified resource. + * + * @param {String} resourceType + * One of ResourceCommand.TYPES + * @return {Boolean} + * If the resources type is beibg watched. + */ + isResourceWatched(resourceType) { + return this._listenedResources.has(resourceType); + } + + /** + * Start watching for all already existing and future targets. + * + * We are using ALL_TYPES, but this won't force listening to all types. + * It will only listen for types which are defined by `TargetCommand.startListening`. + */ + async _watchAllTargets() { + if (!this._watchTargetsPromise) { + // If this is the very first listener registered, of all kind of resource types: + // * we want to start observing targets via TargetCommand + // * _onTargetAvailable will be called for each already existing targets and the next one to come + this._watchTargetsPromise = this.targetCommand.watchTargets({ + types: this.targetCommand.ALL_TYPES, + onAvailable: this._onTargetAvailable, + onDestroyed: this._onTargetDestroyed, + }); + } + return this._watchTargetsPromise; + } + + _unwatchAllTargets() { + if (!this._watchTargetsPromise) { + return; + } + + for (const offList of this._offTargetFrontListeners.values()) { + offList.forEach(off => off()); + } + this._offTargetFrontListeners.clear(); + + this._watchTargetsPromise = null; + this.targetCommand.unwatchTargets({ + types: this.targetCommand.ALL_TYPES, + onAvailable: this._onTargetAvailable, + onDestroyed: this._onTargetDestroyed, + }); + } + + /** + * For a given resource type, start the legacy listeners for all already existing targets. + * Do that only if we have to. If this resourceType requires legacy listeners. + */ + async _startLegacyListenersForExistingTargets(resourceType) { + // If we were already listening to targets, we want to start the legacy listeners + // for all already existing targets. + // + // Only try instantiating the legacy listener, if this resource type: + // - has legacy listener implementation + // (new resource types may not be supported by old runtime and just not be received without breaking anything) + // - isn't supported by the server, or, the target type requires the a legacy listener implementation. + const shouldRunLegacyListeners = + resourceType in LegacyListeners && + (!this.hasResourceCommandSupport(resourceType) || + this._shouldRunLegacyListenerEvenWithWatcherSupport(resourceType)); + if (shouldRunLegacyListeners) { + const promises = []; + const targets = this.targetCommand.getAllTargets( + this.targetCommand.ALL_TYPES + ); + for (const targetFront of targets) { + // We disable warning in case we already registered the legacy listener for this target + // as this code may race with the call from onTargetAvailable if we end up having multiple + // calls to _startListening in parallel. + promises.push( + this._watchResourcesForTarget({ + targetFront, + resourceType, + disableWarning: true, + }) + ); + } + await Promise.all(promises); + } + } + + /** + * Method called by the TargetCommand for each already existing or target which has just been created. + * + * @param {Object} arg + * @param {Front} arg.targetFront + * The Front of the target that is available. + * This Front inherits from TargetMixin and is typically + * composed of a WindowGlobalTargetFront or ContentProcessTargetFront. + * @param {Boolean} arg.isTargetSwitching + * true when the new target was created because of a target switching. + */ + async _onTargetAvailable({ targetFront, isTargetSwitching }) { + const resources = []; + if (isTargetSwitching) { + // WatcherActor currently only watches additional frame targets and + // explicitely ignores top level one that may be created when navigating + // to a new process. + // In order to keep working resources that are being watched via the + // Watcher actor, we have to unregister and re-register the resource + // types. This will force calling `Resources.watchResources` on the new top + // level target. + for (const resourceType of Object.values(ResourceCommand.TYPES)) { + // ...which has at least one listener... + if (!this._listenedResources.has(resourceType)) { + continue; + } + + if (this._shouldRestartListenerOnTargetSwitching(resourceType)) { + this._stopListening(resourceType, { + bypassListenerCount: true, + }); + resources.push(resourceType); + } + } + } + + if (targetFront.isDestroyed()) { + return; + } + + // If we are target switching, we already stop & start listening to all the + // currently monitored resources. + if (!isTargetSwitching) { + // For each resource type... + for (const resourceType of Object.values(ResourceCommand.TYPES)) { + // ...which has at least one listener... + if (!this._listenedResources.has(resourceType)) { + continue; + } + // ...request existing resource and new one to come from this one target + // *but* only do that for backward compat, where we don't have the watcher API + // (See bug 1626647) + await this._watchResourcesForTarget({ targetFront, resourceType }); + } + } + + // Compared to the TargetCommand and Watcher.watchTargets, + // We do call Watcher.watchResources, but the events are fired on the target. + // That's because the Watcher runs in the parent process/main thread, while resources + // are available from the target's process/thread. + const offResourceAvailable = targetFront.on( + "resource-available-form", + this._onResourceAvailable.bind(this, { targetFront }) + ); + const offResourceUpdated = targetFront.on( + "resource-updated-form", + this._onResourceUpdated.bind(this, { targetFront }) + ); + const offResourceDestroyed = targetFront.on( + "resource-destroyed-form", + this._onResourceDestroyed.bind(this, { targetFront }) + ); + + const offList = this._offTargetFrontListeners.get(targetFront) || []; + offList.push( + offResourceAvailable, + offResourceUpdated, + offResourceDestroyed + ); + + if (isTargetSwitching) { + await Promise.all( + resources.map(resourceType => + this._startListening(resourceType, { + bypassListenerCount: true, + }) + ) + ); + } + + // DOCUMENT_EVENT's will-navigate should replace target actor's will-navigate event, + // but only for targets provided by the watcher actor. + // Emit a fake DOCUMENT_EVENT's "will-navigate" out of target actor's will-navigate + // until watcher actor is supported by all descriptors (bug 1675763). + if (!this.targetCommand.hasTargetWatcherSupport()) { + const offWillNavigate = targetFront.on( + "will-navigate", + ({ url, isFrameSwitching }) => { + targetFront.emit("resource-available-form", [ + { + resourceType: this.TYPES.DOCUMENT_EVENT, + name: "will-navigate", + time: Date.now(), // will-navigate was not passing any timestamp + isFrameSwitching, + newURI: url, + }, + ]); + } + ); + offList.push(offWillNavigate); + } + + this._offTargetFrontListeners.set(targetFront, offList); + } + + _shouldRestartListenerOnTargetSwitching(resourceType) { + // Note that we aren't using isServerTargetSwitchingEnabled, nor checking the + // server side target switching preference as we may have server side targets + // even when this is false/disabled. + // This will happen for bfcache navigations, even with server side targets disabled. + // `followWindowGlobalLifeCycle` will be false for the first top level target + // and only become true when doing a bfcache navigation. + // (only server side targets follow the WindowGlobal lifecycle) + // When server side targets are enabled, this will always be true. + const isServerSideTarget = + this.targetCommand.targetFront.targetForm.followWindowGlobalLifeCycle; + if (isServerSideTarget) { + // For top-level targets created from the server, only restart legacy + // listeners. + return !this.hasResourceCommandSupport(resourceType); + } + + // For top-level targets created from the client we should always restart + // listeners. + return true; + } + + /** + * Method called by the TargetCommand when a target has just been destroyed + * @param {Object} arg + * @param {Front} arg.targetFront + * The Front of the target that was destroyed + * @param {Boolean} arg.isModeSwitching + * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref. + */ + _onTargetDestroyed({ targetFront, isModeSwitching }) { + // Clear the map of legacy listeners for this target. + this._existingLegacyListeners.set(targetFront, []); + this._offTargetFrontListeners.delete(targetFront); + + // Purge the cache from any resource related to the destroyed target. + // Top level BrowsingContext target will be purge via DOCUMENT_EVENT will-navigate events. + // If we were to clean resources from target-destroyed, we will clear resources + // happening between will-navigate and target-destroyed. Typically the navigation request + // At the moment, isModeSwitching can only be true when targetFront.isTopLevel isn't true, + // so we don't need to add a specific check for isModeSwitching. + if (!targetFront.isTopLevel || !targetFront.isBrowsingContext) { + for (const [key, resource] of this._cache) { + if (resource.targetFront === targetFront) { + // NOTE: To anyone paranoid like me, yes it is okay to delete from a Map while iterating it. + this._cache.delete(key); + } + } + } + + // Purge "available" pendingEvents for resources from the destroyed target when switching + // mode as we want to ignore those. + if (isModeSwitching) { + for (const watcherEntry of this._watchers) { + for (const pendingEvent of watcherEntry.pendingEvents) { + if (pendingEvent.callbackType == "available") { + pendingEvent.updates = pendingEvent.updates.filter( + update => update.targetFront !== targetFront + ); + } + } + } + } + } + + /** + * Method called either by: + * - the backward compatibility code (LegacyListeners) + * - target actors RDP events + * whenever an already existing resource is being listed or when a new one + * has been created. + * + * @param {Object} source + * A dictionary object with only one of these two attributes: + * - targetFront: a Target Front, if the resource is watched from the target process or thread + * - watcherFront: a Watcher Front, if the resource is watched from the parent process + * @param {Array<json/Front>} resources + * Depending on the resource Type, it can be an Array composed of either JSON objects or Fronts, + * which describes the resource. + */ + async _onResourceAvailable({ targetFront, watcherFront }, resources) { + let includesDocumentEventWillNavigate = false; + let includesDocumentEventDomLoading = false; + for (let resource of resources) { + const { resourceType } = resource; + + if (watcherFront) { + targetFront = await this._getTargetForWatcherResource(resource); + // When we receive resources from the Watcher actor, + // there is no guarantee that the target front is fully initialized. + // The Target Front is initialized by the TargetCommand, by calling TargetFront.attachAndInitThread. + // We have to wait for its completion as resources watchers are expecting it to be completed. + // + // But when navigating, we may receive resources packets for a destroyed target. + // Or, in the context of the browser toolbox, they may not relate to any target. + if (targetFront) { + await targetFront.initialized; + } + } + + // isAlreadyExistingResource indicates that the resources already existed before + // the resource command started watching for this type of resource. + resource.isAlreadyExistingResource = + this._processingExistingResources.has(resourceType); + + // Put the targetFront on the resource for easy retrieval. + // (Resources from the legacy listeners may already have the attribute set) + if (!resource.targetFront) { + resource.targetFront = targetFront; + } + + if (ResourceTransformers[resourceType]) { + resource = ResourceTransformers[resourceType]({ + resource, + targetCommand: this.targetCommand, + targetFront, + watcherFront: this.watcherFront, + }); + } + + if (!resource.resourceId) { + resource.resourceId = `auto:${++gLastResourceId}`; + } + + // Only consider top level document, and ignore remote iframes top document + const isWillNavigate = + resourceType == ResourceCommand.TYPES.DOCUMENT_EVENT && + resource.name == "will-navigate"; + if (isWillNavigate && resource.targetFront.isTopLevel) { + includesDocumentEventWillNavigate = true; + this._onWillNavigate(resource.targetFront); + } + + if ( + resourceType == ResourceCommand.TYPES.DOCUMENT_EVENT && + resource.name == "dom-loading" && + resource.targetFront.isTopLevel + ) { + includesDocumentEventDomLoading = true; + } + + this._queueResourceEvent("available", resourceType, resource); + + // Avoid storing will-navigate resource and consider it as a transcient resource. + // We do that to prevent leaking this resource (and its target) on navigation. + // We do clear the cache in _onWillNavigate, that we call a few lines before this. + if (!isWillNavigate) { + this.addResourceToCache(resource); + } + } + + // If we receive the DOCUMENT_EVENT for: + // - will-navigate + // - dom-loading + we're using the service worker legacy listener + // then flush immediately the resources to notify about the navigation sooner than later. + // (this is especially useful for tests, even if they should probably avoid depending on this...) + if ( + includesDocumentEventWillNavigate || + (includesDocumentEventDomLoading && + !this.targetCommand.hasTargetWatcherSupport("service_worker")) || + this.throttlingDisabled + ) { + this._notifyWatchers(); + } else { + this._throttledNotifyWatchers(); + } + } + + /** + * Method called either by: + * - the backward compatibility code (LegacyListeners) + * - target actors RDP events + * Called everytime a resource is updated in the remote target. + * + * @param {Object} source + * Please see _onResourceAvailable for this parameter. + * @param {Array<Object>} updates + * Depending on the listener. + * + * Among the element in the array, the following attributes are given special handling. + * - resourceType {String}: + * The type of resource to be updated. + * - resourceId {String}: + * The id of resource to be updated. + * - resourceUpdates {Object}: + * If resourceUpdates is in the element, a cached resource specified by resourceType + * and resourceId is updated by Object.assign(cachedResource, resourceUpdates). + * - nestedResourceUpdates {Object}: + * If `nestedResourceUpdates` is passed, update one nested attribute with a new value + * This allows updating one attribute of an object stored in a resource's attribute, + * as well as adding new elements to arrays. + * `path` is an array mentioning all nested attribute to walk through. + * `value` is the new nested attribute value to set. + * + * And also, the element is passed to the listener as it is as “update” object. + * So if we don't want to update a cached resource but have information want to + * pass on to the listener, can pass it on using attributes other than the ones + * listed above. + * For example, if the element consists of like + * "{ resourceType:… resourceId:…, testValue: “test”, }”, + * the listener can receive the value as follows. + * + * onResourceUpdate({ update }) { + * console.log(update.testValue); // “test” should be displayed + * } + */ + async _onResourceUpdated({ targetFront, watcherFront }, updates) { + for (const update of updates) { + const { + resourceType, + resourceId, + resourceUpdates, + nestedResourceUpdates, + } = update; + + if (!resourceId) { + console.warn(`Expected resource ${resourceType} to have a resourceId`); + } + + // See _onResourceAvailable() + // We also need to wait for the related targetFront to be initialized + // otherwise we would notify about the udpate *before* the available + // and the resource won't be in _cache. + if (watcherFront) { + targetFront = await this._getTargetForWatcherResource(update); + // When we receive the navigation request, the target front has already been + // destroyed, but this is fine. The cached resource has the reference to + // the (destroyed) target front and it is fully initialized. + if (targetFront) { + await targetFront.initialized; + } + } + + const existingResource = this._cache.get( + cacheKey(resourceType, resourceId) + ); + if (!existingResource) { + continue; + } + + if (resourceUpdates) { + Object.assign(existingResource, resourceUpdates); + } + + if (nestedResourceUpdates) { + for (const { path, value } of nestedResourceUpdates) { + let target = existingResource; + + for (let i = 0; i < path.length - 1; i++) { + target = target[path[i]]; + } + + target[path[path.length - 1]] = value; + } + } + this._queueResourceEvent("updated", resourceType, { + resource: existingResource, + update, + }); + } + this._throttledNotifyWatchers(); + } + + /** + * Called everytime a resource is destroyed in the remote target. + * See _onResourceAvailable for the argument description. + */ + async _onResourceDestroyed({ targetFront, watcherFront }, resources) { + for (const resource of resources) { + const { resourceType, resourceId } = resource; + this._cache.delete(cacheKey(resourceType, resourceId)); + if (!resource.targetFront) { + resource.targetFront = targetFront; + } + this._queueResourceEvent("destroyed", resourceType, resource); + } + this._throttledNotifyWatchers(); + } + + _queueResourceEvent(callbackType, resourceType, update) { + for (const { resources, pendingEvents } of this._watchers) { + // This watcher doesn't listen to this type of resource + if (!resources.includes(resourceType)) { + continue; + } + // If we receive a new event of the same type, accumulate the new update in the last event + if (pendingEvents.length) { + const lastEvent = pendingEvents[pendingEvents.length - 1]; + if (lastEvent.callbackType == callbackType) { + lastEvent.updates.push(update); + continue; + } + } + // Otherwise, pile up a new event, which will force calling watcher + // callback a new time + pendingEvents.push({ + callbackType, + updates: [update], + }); + } + } + + /** + * Flush the pending event and notify all the currently registered watchers + * about all the available, updated and destroyed events that have been accumulated in + * `_watchers`'s `pendingEvents` arrays. + */ + _notifyWatchers() { + for (const watcherEntry of this._watchers) { + const { onAvailable, onUpdated, onDestroyed, pendingEvents } = + watcherEntry; + // Immediately clear the buffer in order to avoid possible races, where an event listener + // would end up somehow adding a new throttled resource + watcherEntry.pendingEvents = []; + + for (const { callbackType, updates } of pendingEvents) { + try { + if (callbackType == "available") { + onAvailable(updates, { areExistingResources: false }); + } else if (callbackType == "updated" && onUpdated) { + onUpdated(updates); + } else if (callbackType == "destroyed" && onDestroyed) { + onDestroyed(updates); + } + } catch (e) { + console.error( + "Exception while calling a ResourceCommand", + callbackType, + "callback", + ":", + e + ); + } + } + } + } + + // Compute the target front if the resource comes from the Watcher Actor. + // (`targetFront` will be null as the watcher is in the parent process + // and targets are in distinct processes) + _getTargetForWatcherResource(resource) { + const { browsingContextID, innerWindowId, resourceType } = resource; + + // Some privileged resources aren't related to any BrowsingContext + // and so aren't bound to any Target Front. + // Server watchers should pass an explicit "-1" value in order to prevent + // silently ignoring an undefined browsingContextID attribute. + if (browsingContextID == -1) { + return null; + } + + if (innerWindowId && this.targetCommand.isServerTargetSwitchingEnabled()) { + return this.watcherFront.getWindowGlobalTargetByInnerWindowId( + innerWindowId + ); + } else if (browsingContextID) { + return this.watcherFront.getWindowGlobalTarget(browsingContextID); + } + console.error( + `Resource of ${resourceType} is missing a browsingContextID or innerWindowId attribute` + ); + return null; + } + + _onWillNavigate(targetFront) { + // Special case for toolboxes debugging a document, + // purge the cache entirely when we start navigating to a new document. + // Other toolboxes and additional target for remote iframes or content process + // will be purge from onTargetDestroyed. + + // NOTE: we could `clear` the cache here, but technically if anything is + // currently iterating over resources provided by getAllResources, that + // would interfere with their iteration. We just assign a new Map here to + // leave those iterators as is. + this._cache = new Map(); + } + + /** + * Tells if the server supports listening to the given resource type + * via the watcher actor's watchResources method. + * + * @return {Boolean} True, if the server supports this type. + */ + hasResourceCommandSupport(resourceType) { + return this.watcherFront?.traits?.resources?.[resourceType]; + } + + /** + * Tells if the server supports listening to the given resource type + * via the watcher actor's watchResources method, and that, for a specific + * target. + * + * @return {Boolean} True, if the server supports this type. + */ + _hasResourceCommandSupportForTarget(resourceType, targetFront) { + // First check if the watcher supports this target type. + // If it doesn't, no resource type can be listened via the Watcher actor for this target. + if (!this.targetCommand.hasTargetWatcherSupport(targetFront.targetType)) { + return false; + } + + return this.hasResourceCommandSupport(resourceType); + } + + _isValidResourceType(type) { + return this.ALL_TYPES.includes(type); + } + + /** + * Start listening for a given type of resource. + * For backward compatibility code, we register the legacy listeners on + * each individual target + * + * @param {String} resourceType + * One string of ResourceCommand.TYPES, which designates the types of resources + * to be listened. + * @param {Object} + * - {Boolean} bypassListenerCount + * Pass true to avoid checking/updating the listenersCount map. + * Exclusively used when target switching, to stop & start listening + * to all resources. + */ + async _startListening(resourceType, { bypassListenerCount = false } = {}) { + if (!bypassListenerCount) { + if (this._listenedResources.has(resourceType)) { + return; + } + this._listenedResources.add(resourceType); + } + + this._processingExistingResources.add(resourceType); + + // Ensuring enabling listening to targets. + // This will be a no-op expect for the very first call to `_startListening`, + // where it is going to call `onTargetAvailable` for all already existing targets, + // as well as for those who will be created later. + // + // Do this *before* calling WatcherActor.watchResources in order to register "resource-available" + // listeners on targets before these events start being emitted. + await this._watchAllTargets(resourceType); + + // When we are calling _startListening for the first time, _watchAllTargets + // will register legacylistener when it will call onTargetAvailable for all existing targets. + // But for any next calls to _startListening, _watchAllTargets will be a no-op, + // and nothing will start legacy listener for each already registered targets. + await this._startLegacyListenersForExistingTargets(resourceType); + + // If the server supports the Watcher API and the Watcher supports + // this resource type, use this API + if (this.hasResourceCommandSupport(resourceType)) { + await this.watcherFront.watchResources([resourceType]); + } + this._processingExistingResources.delete(resourceType); + } + + /** + * Return true if the resource should be watched via legacy listener, + * even when watcher supports this resource type. + * + * Bug 1678385: In order to support watching for JS Source resource + * for service workers and parent process workers, which aren't supported yet + * by the watcher actor, we do not bail out here and allow to execute + * the legacy listener for these targets. + * Once bug 1608848 is fixed, we can remove this and never trigger + * the legacy listeners codepath for these resource types. + * + * If this isn't fixed soon, we may add other resources we want to see + * being fetched from these targets. + */ + _shouldRunLegacyListenerEvenWithWatcherSupport(resourceType) { + return WORKER_RESOURCE_TYPES.includes(resourceType); + } + + async _forwardExistingResources(resourceTypes, onAvailable) { + const existingResources = []; + for (const resource of this._cache.values()) { + if (resourceTypes.includes(resource.resourceType)) { + existingResources.push(resource); + } + } + if (existingResources.length) { + await onAvailable(existingResources, { areExistingResources: true }); + } + } + + /** + * Call backward compatibility code from `LegacyListeners` in order to listen for a given + * type of resource from a given target. + */ + async _watchResourcesForTarget({ + targetFront, + resourceType, + disableWarning = false, + }) { + if (this._hasResourceCommandSupportForTarget(resourceType, targetFront)) { + // This resource / target pair should already be handled by the watcher, + // no need to start legacy listeners. + return; + } + + // All workers target types are still not supported by the watcher + // so that we have to spawn legacy listener for all their resources. + // But some resources are irrelevant to workers, like network events. + // And we removed the related legacy listener as they are no longer used. + if ( + targetFront.targetType.endsWith("worker") && + !WORKER_RESOURCE_TYPES.includes(resourceType) + ) { + return; + } + + if (targetFront.isDestroyed()) { + return; + } + + const onAvailable = this._onResourceAvailable.bind(this, { targetFront }); + const onUpdated = this._onResourceUpdated.bind(this, { targetFront }); + const onDestroyed = this._onResourceDestroyed.bind(this, { targetFront }); + + if (!(resourceType in LegacyListeners)) { + throw new Error(`Missing legacy listener for ${resourceType}`); + } + + const legacyListeners = + this._existingLegacyListeners.get(targetFront) || []; + if (legacyListeners.includes(resourceType)) { + if (!disableWarning) { + console.warn( + `Already started legacy listener for ${resourceType} on ${targetFront.actorID}` + ); + } + return; + } + this._existingLegacyListeners.set( + targetFront, + legacyListeners.concat(resourceType) + ); + + try { + await LegacyListeners[resourceType]({ + targetCommand: this.targetCommand, + targetFront, + onAvailable, + onDestroyed, + onUpdated, + }); + } catch (e) { + // Swallow the error to avoid breaking calls to watchResources which will + // loop on all existing targets to create legacy listeners. + // If a legacy listener fails to handle a target for some reason, we + // should still try to process other targets as much as possible. + // See Bug 1687645. + console.error( + `Failed to start [${resourceType}] legacy listener for target ${targetFront.actorID}`, + e + ); + } + } + + /** + * Reverse of _startListening. Stop listening for a given type of resource. + * For backward compatibility, we unregister from each individual target. + * + * See _startListening for parameters description. + */ + _stopListening(resourceType, { bypassListenerCount = false } = {}) { + if (!bypassListenerCount) { + if (!this._listenedResources.has(resourceType)) { + throw new Error( + `Stopped listening for resource '${resourceType}' that isn't being listened to` + ); + } + this._listenedResources.delete(resourceType); + } + + // Clear the cached resources of the type. + for (const [key, resource] of this._cache) { + if (resource.resourceType == resourceType) { + // NOTE: To anyone paranoid like me, yes it is okay to delete from a Map while iterating it. + this._cache.delete(key); + } + } + + // If the server supports the Watcher API and the Watcher supports + // this resource type, use this API + if (this.hasResourceCommandSupport(resourceType)) { + if (!this.watcherFront.isDestroyed()) { + this.watcherFront.unwatchResources([resourceType]); + } + + const shouldRunLegacyListeners = + this._shouldRunLegacyListenerEvenWithWatcherSupport(resourceType); + if (!shouldRunLegacyListeners) { + return; + } + } + // Otherwise, fallback on backward compat mode and use LegacyListeners. + + // If this was the last listener, we should stop watching these events from the actors + // and the actors should stop watching things from the platform + const targets = this.targetCommand.getAllTargets( + this.targetCommand.ALL_TYPES + ); + for (const target of targets) { + this._unwatchResourcesForTarget(target, resourceType); + } + } + + /** + * Backward compatibility code, reverse of _watchResourcesForTarget. + */ + _unwatchResourcesForTarget(targetFront, resourceType) { + if (this._hasResourceCommandSupportForTarget(resourceType, targetFront)) { + // This resource / target pair should already be handled by the watcher, + // no need to stop legacy listeners. + } + // Is there really a point in: + // - unregistering `onAvailable` RDP event callbacks from target-scoped actors? + // - calling `stopListeners()` as we are most likely closing the toolbox and destroying everything? + // + // It is important to keep this method synchronous and do as less as possible + // in the case of toolbox destroy. + // + // We are aware of one case where that might be useful. + // When a panel is disabled via the options panel, after it has been opened. + // Would that justify doing this? Is there another usecase? + + // XXX: This is most likely only needed to avoid growing the Map infinitely. + // Unless in the "disabled panel" use case mentioned in the comment above, + // we should not see the same target actorID again. + const listeners = this._existingLegacyListeners.get(targetFront); + if (listeners && listeners.includes(resourceType)) { + const remainingListeners = listeners.filter(l => l !== resourceType); + this._existingLegacyListeners.set(targetFront, remainingListeners); + } + } +} + +ResourceCommand.TYPES = ResourceCommand.prototype.TYPES = { + CONSOLE_MESSAGE: "console-message", + CSS_CHANGE: "css-change", + CSS_MESSAGE: "css-message", + CSS_REGISTERED_PROPERTIES: "css-registered-properties", + ERROR_MESSAGE: "error-message", + PLATFORM_MESSAGE: "platform-message", + DOCUMENT_EVENT: "document-event", + ROOT_NODE: "root-node", + STYLESHEET: "stylesheet", + NETWORK_EVENT: "network-event", + WEBSOCKET: "websocket", + COOKIE: "cookies", + LOCAL_STORAGE: "local-storage", + SESSION_STORAGE: "session-storage", + CACHE_STORAGE: "Cache", + EXTENSION_STORAGE: "extension-storage", + INDEXED_DB: "indexed-db", + NETWORK_EVENT_STACKTRACE: "network-event-stacktrace", + REFLOW: "reflow", + SOURCE: "source", + THREAD_STATE: "thread-state", + JSTRACER_TRACE: "jstracer-trace", + JSTRACER_STATE: "jstracer-state", + SERVER_SENT_EVENT: "server-sent-event", + LAST_PRIVATE_CONTEXT_EXIT: "last-private-context-exit", +}; +ResourceCommand.ALL_TYPES = ResourceCommand.prototype.ALL_TYPES = Object.values( + ResourceCommand.TYPES +); +module.exports = ResourceCommand; + +// This is the list of resource types supported by workers. +// We need such list to know when forcing to run the legacy listeners +// and when to avoid try to spawn some unsupported ones for workers. +const WORKER_RESOURCE_TYPES = [ + ResourceCommand.TYPES.CONSOLE_MESSAGE, + ResourceCommand.TYPES.ERROR_MESSAGE, + ResourceCommand.TYPES.SOURCE, + ResourceCommand.TYPES.THREAD_STATE, +]; + +// Backward compat code for each type of resource. +// Each section added here should eventually be removed once the equivalent server +// code is implement in Firefox, in its release channel. +const LegacyListeners = { + async [ResourceCommand.TYPES.DOCUMENT_EVENT]({ + targetCommand, + targetFront, + onAvailable, + }) { + // DocumentEventsListener of webconsole handles only top level document. + if (!targetFront.isTopLevel) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + webConsoleFront.on("documentEvent", event => { + event.resourceType = ResourceCommand.TYPES.DOCUMENT_EVENT; + onAvailable([event]); + }); + await webConsoleFront.startListeners(["DocumentEvents"]); + }, +}; +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.CONSOLE_MESSAGE, + "resource://devtools/shared/commands/resource/legacy-listeners/console-messages.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.CSS_CHANGE, + "resource://devtools/shared/commands/resource/legacy-listeners/css-changes.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.CSS_MESSAGE, + "resource://devtools/shared/commands/resource/legacy-listeners/css-messages.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.ERROR_MESSAGE, + "resource://devtools/shared/commands/resource/legacy-listeners/error-messages.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.PLATFORM_MESSAGE, + "resource://devtools/shared/commands/resource/legacy-listeners/platform-messages.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.ROOT_NODE, + "resource://devtools/shared/commands/resource/legacy-listeners/root-node.js" +); + +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.SOURCE, + "resource://devtools/shared/commands/resource/legacy-listeners/source.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.THREAD_STATE, + "resource://devtools/shared/commands/resource/legacy-listeners/thread-states.js" +); + +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.REFLOW, + "resource://devtools/shared/commands/resource/legacy-listeners/reflow.js" +); + +// Optional transformers for each type of resource. +// Each module added here should be a function that will receive the resource, the target, … +// and perform some transformation on the resource before it will be emitted. +// This is a good place to handle backward compatibility and manual resource marshalling. +const ResourceTransformers = {}; + +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.CONSOLE_MESSAGE, + "resource://devtools/shared/commands/resource/transformers/console-messages.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.ERROR_MESSAGE, + "resource://devtools/shared/commands/resource/transformers/error-messages.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.CACHE_STORAGE, + "resource://devtools/shared/commands/resource/transformers/storage-cache.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.COOKIE, + "resource://devtools/shared/commands/resource/transformers/storage-cookie.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.EXTENSION_STORAGE, + "resource://devtools/shared/commands/resource/transformers/storage-extension.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.INDEXED_DB, + "resource://devtools/shared/commands/resource/transformers/storage-indexed-db.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.LOCAL_STORAGE, + "resource://devtools/shared/commands/resource/transformers/storage-local-storage.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.SESSION_STORAGE, + "resource://devtools/shared/commands/resource/transformers/storage-session-storage.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.NETWORK_EVENT, + "resource://devtools/shared/commands/resource/transformers/network-events.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.THREAD_STATE, + "resource://devtools/shared/commands/resource/transformers/thread-states.js" +); diff --git a/devtools/shared/commands/resource/tests/breakpoint_document.html b/devtools/shared/commands/resource/tests/breakpoint_document.html new file mode 100644 index 0000000000..2291094646 --- /dev/null +++ b/devtools/shared/commands/resource/tests/breakpoint_document.html @@ -0,0 +1,21 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf8"> + <title>Test breakpoint document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + </head> + <body> + <script> + "use strict"; + /* eslint-disable */ + function testFunction() { + console.log("test Function ran"); + } + function runDebuggerStatement() { + debugger; + } + </script> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/browser.toml b/devtools/shared/commands/resource/tests/browser.toml new file mode 100644 index 0000000000..def009b710 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser.toml @@ -0,0 +1,128 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", + "!/devtools/client/shared/test/highlighter-test-actor.js", + "head.js", + "breakpoint_document.html", + "doc_console.html", + "doc_console_iframe.html", + "empty.html", + "network_document.html", + "network_document_navigation.html", + "network_navigation.js", + "early_console_document.html", + "fission_document.html", + "fission_document_workers.html", + "fission_iframe.html", + "fission_iframe_workers.html", + "service-worker-sources.js", + "sources.html", + "sources.js", + "sse_backend.sjs", + "sse_frontend_iframe.html", + "sse_frontend.html", + "style_document.css", + "style_document.html", + "style_iframe.css", + "style_iframe.html", + "stylesheets-nested-iframes.html", + "test_image.png", + "test_service_worker.js", + "test_worker.js", + "websocket_backend_wsh.py", + "websocket_frontend_iframe.html", + "websocket_frontend.html", + "worker-sources.js", +] + +["browser_browser_resources_console_messages.js"] + +["browser_resources_clear_resources.js"] + +["browser_resources_client_caching.js"] + +["browser_resources_console_messages.js"] + +["browser_resources_console_messages_navigation.js"] + +["browser_resources_console_messages_workers.js"] + +["browser_resources_css_changes.js"] + +["browser_resources_css_messages.js"] + +["browser_resources_css_registered_properties.js"] +skip-if = ["!fission"] + +["browser_resources_document_events.js"] +skip-if = [ + "os == 'linux' && bits == 64", # Bug 1715878 +] + +["browser_resources_error_messages.js"] + +["browser_resources_getAllResources.js"] + +["browser_resources_invalid_api_usage.js"] + +["browser_resources_last_private_context_exit.js"] + +["browser_resources_network_event_stacktraces.js"] + +["browser_resources_network_events.js"] + +["browser_resources_network_events_cache.js"] + +["browser_resources_network_events_navigation.js"] + +["browser_resources_network_events_parent_process.js"] + +["browser_resources_platform_messages.js"] + +["browser_resources_reflows.js"] + +["browser_resources_root_node.js"] + +["browser_resources_scope_flag.js"] + +["browser_resources_server_sent_events.js"] + +["browser_resources_several_resources.js"] + +["browser_resources_sources.js"] +skip-if = [ + "os == 'linux' && bits == 64", # Bug 1744565 + "win11_2009", # Bug 1767772 + "apple_catalina", # Bug 1767772 +] + +["browser_resources_stylesheets.js"] + +["browser_resources_stylesheets_header.js"] + +["browser_resources_stylesheets_import.js"] + +["browser_resources_stylesheets_navigation.js"] + +["browser_resources_stylesheets_nested_iframes.js"] + +["browser_resources_target_destroy.js"] + +["browser_resources_target_resources_race.js"] + +["browser_resources_target_switching.js"] + +["browser_resources_thread_states.js"] + +["browser_resources_unwatch_early.js"] + +["browser_resources_watch_unwatch_multiple.js"] + +["browser_resources_websocket.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] diff --git a/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js b/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js new file mode 100644 index 0000000000..1c6c776e64 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CONSOLE_MESSAGE for the whole browser + +const TEST_URL = URL_ROOT_SSL + "early_console_document.html"; + +add_task(async function () { + // Enable Multiprocess Browser Toolbox. + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + const d = Date.now(); + const CACHED_MESSAGE_TEXT = `cached-${d}`; + const LIVE_MESSAGE_TEXT = `live-${d}`; + + info( + "Log some messages *before* calling ResourceCommand.watchResources in order to " + + "assert the behavior of already existing messages." + ); + console.log(CACHED_MESSAGE_TEXT); + + info("Wait for existing browser mochitest log"); + const { onResource } = await resourceCommand.waitForNextResource( + resourceCommand.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: false, + predicate({ message }) { + return message.arguments[0] === CACHED_MESSAGE_TEXT; + }, + } + ); + const existingMsg = await onResource; + ok(existingMsg, "The existing log was retrieved"); + is( + existingMsg.isAlreadyExistingResource, + true, + "isAlreadyExistingResource is true for the existing message" + ); + + const { onResource: onMochitestRuntimeLog } = + await resourceCommand.waitForNextResource( + resourceCommand.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: false, + predicate({ message }) { + return message.arguments[0] === LIVE_MESSAGE_TEXT; + }, + } + ); + console.log(LIVE_MESSAGE_TEXT); + + info("Wait for runtime browser mochitest log"); + const runtimeLogResource = await onMochitestRuntimeLog; + ok(runtimeLogResource, "The runtime log was retrieved"); + is( + runtimeLogResource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false for the runtime message" + ); + + const { onResource: onEarlyLog } = await resourceCommand.waitForNextResource( + resourceCommand.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: true, + predicate({ message }) { + return message.arguments[0] === "early-page-log"; + }, + } + ); + await addTab(TEST_URL); + info("Wait for early page log"); + const earlyResource = await onEarlyLog; + ok(earlyResource, "The early page log was retrieved"); + is( + earlyResource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false for the early message" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js b/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js new file mode 100644 index 0000000000..44068cb141 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the clearResources function of the ResourceCommand + +add_task(async () => { + const tab = await addTab(`${URL_ROOT_SSL}empty.html`); + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Assert the initial no of resources"); + assertNoOfResources(resourceCommand, 0, 0); + + const onAvailable = () => {}; + const onUpdated = () => {}; + + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.NETWORK_EVENT, + ], + { onAvailable, onUpdated } + ); + + info("Log some messages"); + await logConsoleMessages(tab.linkedBrowser, ["log1", "log2", "log3"]); + + info("Trigger some network requests"); + const EXAMPLE_DOMAIN = "https://example.com/"; + await triggerNetworkRequests(tab.linkedBrowser, [ + `await fetch("${EXAMPLE_DOMAIN}/request1.html", { method: "GET" });`, + `await fetch("${EXAMPLE_DOMAIN}/request2.html", { method: "GET" });`, + ]); + + assertNoOfResources(resourceCommand, 3, 2); + + info("Clear the network event resources"); + await resourceCommand.clearResources([resourceCommand.TYPES.NETWORK_EVENT]); + assertNoOfResources(resourceCommand, 3, 0); + + info("Clear the console message resources"); + await resourceCommand.clearResources([resourceCommand.TYPES.CONSOLE_MESSAGE]); + assertNoOfResources(resourceCommand, 0, 0); + + resourceCommand.unwatchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.NETWORK_EVENT, + ], + { onAvailable, onUpdated, ignoreExistingResources: true } + ); + + targetCommand.destroy(); + await client.close(); +}); + +function assertNoOfResources( + resourceCommand, + expectedNoOfConsoleMessageResources, + expectedNoOfNetworkEventResources +) { + const actualNoOfConsoleMessageResources = resourceCommand.getAllResources( + resourceCommand.TYPES.CONSOLE_MESSAGE + ).length; + is( + actualNoOfConsoleMessageResources, + expectedNoOfConsoleMessageResources, + `There are ${actualNoOfConsoleMessageResources} console messages resources` + ); + + const actualNoOfNetworkEventResources = resourceCommand.getAllResources( + resourceCommand.TYPES.NETWORK_EVENT + ).length; + is( + actualNoOfNetworkEventResources, + expectedNoOfNetworkEventResources, + `There are ${actualNoOfNetworkEventResources} network event resources` + ); +} + +function logConsoleMessages(browser, messages) { + return SpecialPowers.spawn(browser, [messages], innerMessages => { + for (const message of innerMessages) { + content.console.log(message); + } + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_client_caching.js b/devtools/shared/commands/resource/tests/browser_resources_client_caching.js new file mode 100644 index 0000000000..ae398f73cc --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_client_caching.js @@ -0,0 +1,380 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the cache mechanism of the ResourceCommand. + +const TEST_URI = "data:text/html;charset=utf-8,<!DOCTYPE html>Cache Test"; + +add_task(async function () { + info("Test whether multiple listener can get same cached resources"); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const messages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, messages); + + info("Register first listener"); + const cachedResources1 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + ok(areExistingResources, "All resources are already existing ones"); + cachedResources1.push(...resources); + }, + } + ); + + info("Register second listener"); + const cachedResources2 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + ok(areExistingResources, "All resources are already existing ones"); + cachedResources2.push(...resources); + }, + } + ); + + assertContents(cachedResources1, messages); + assertResources(cachedResources2, cachedResources1); + + targetCommand.destroy(); + await client.close(); +}); + +add_task(async function () { + info( + "Test whether the cache is reflecting existing resources and additional resources" + ); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener to get all available resources"); + const availableResources = []; + // We first get notified about existing resources + let shouldBeExistingResources = true; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + is( + areExistingResources, + shouldBeExistingResources, + "areExistingResources flag is correct" + ); + availableResources.push(...resources); + }, + } + ); + // Then, we are notified about, new, live ones + shouldBeExistingResources = false; + + info("Add messages as additional resources"); + const additionalMessages = ["d", "e"]; + await logMessages(tab.linkedBrowser, additionalMessages); + + info("Wait until onAvailable is called expected times"); + const allMessages = [...existingMessages, ...additionalMessages]; + await waitUntil(() => availableResources.length === allMessages.length); + + info("Register second listener to get the cached resources"); + const cachedResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + ok(areExistingResources, "All resources are already existing ones"); + cachedResources.push(...resources); + }, + } + ); + + assertContents(availableResources, allMessages); + assertResources(cachedResources, availableResources); + + targetCommand.destroy(); + await client.close(); +}); + +add_task(async function () { + info("Test whether the cache is cleared when navigation"); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener"); + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: () => {}, + } + ); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab); + + info("Register second listener"); + const cachedResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources.push(...resources), + } + ); + + is(cachedResources.length, 0, "The cache in ResourceCommand is cleared"); + + targetCommand.destroy(); + await client.close(); +}); + +add_task(async function () { + info("Test with multiple resource types"); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Register first listener to get all available resources"); + const availableResources = []; + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.ERROR_MESSAGE, + ], + { + onAvailable: resources => availableResources.push(...resources), + } + ); + + info("Add messages as console message"); + const consoleMessages1 = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, consoleMessages1); + + info("Add message as error message"); + const errorMessages = ["document.doTheImpossible();"]; + await triggerErrors(tab.linkedBrowser, errorMessages); + + info("Add messages as console message again"); + const consoleMessages2 = ["d", "e"]; + await logMessages(tab.linkedBrowser, consoleMessages2); + + info("Wait until the getting all available resources"); + const totalResourceCount = + consoleMessages1.length + errorMessages.length + consoleMessages2.length; + await waitUntil(() => { + return availableResources.length === totalResourceCount; + }); + + info("Register listener to get the cached resources"); + const cachedResources = []; + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.ERROR_MESSAGE, + ], + { + onAvailable: resources => cachedResources.push(...resources), + } + ); + + assertResources(cachedResources, availableResources); + + targetCommand.destroy(); + await client.close(); +}); + +add_task(async function () { + info("Test multiple listeners with/without ignoreExistingResources"); + await testIgnoreExistingResources(true); + await testIgnoreExistingResources(false); +}); + +async function testIgnoreExistingResources(isFirstListenerIgnoreExisting) { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener"); + const cachedResources1 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources1.push(...resources), + ignoreExistingResources: isFirstListenerIgnoreExisting, + } + ); + + info("Register second listener"); + const cachedResources2 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources2.push(...resources), + ignoreExistingResources: !isFirstListenerIgnoreExisting, + } + ); + + const cachedResourcesWithFlag = isFirstListenerIgnoreExisting + ? cachedResources1 + : cachedResources2; + const cachedResourcesWithoutFlag = isFirstListenerIgnoreExisting + ? cachedResources2 + : cachedResources1; + + info("Check the existing resources both listeners got"); + assertContents(cachedResourcesWithFlag, []); + assertContents(cachedResourcesWithoutFlag, existingMessages); + + info("Add messages as additional resources"); + const additionalMessages = ["d", "e"]; + await logMessages(tab.linkedBrowser, additionalMessages); + + info("Wait until onAvailable is called expected times"); + await waitUntil( + () => cachedResourcesWithFlag.length === additionalMessages.length + ); + const allMessages = [...existingMessages, ...additionalMessages]; + await waitUntil( + () => cachedResourcesWithoutFlag.length === allMessages.length + ); + + info("Check the resources after adding messages"); + assertContents(cachedResourcesWithFlag, additionalMessages); + assertContents(cachedResourcesWithoutFlag, allMessages); + + targetCommand.destroy(); + await client.close(); +} + +add_task(async function () { + info("Test that onAvailable is not called with an empty resources array"); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Register first listener to get all available resources"); + const availableResources = []; + let onAvailableCallCount = 0; + const onAvailable = resources => { + ok( + !!resources.length, + "onAvailable is called with a non empty resources array" + ); + availableResources.push(...resources); + onAvailableCallCount++; + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + is(availableResources.length, 0, "availableResources array is empty"); + is(onAvailableCallCount, 0, "onAvailable was never called"); + + info("Add messages as console message"); + await logMessages(tab.linkedBrowser, ["expected message"]); + + await waitUntil(() => availableResources.length === 1); + is(availableResources.length, 1, "availableResources array has one item"); + is(onAvailableCallCount, 1, "onAvailable was called only once"); + is( + availableResources[0].message.arguments[0], + "expected message", + "onAvailable was called with the expected resource" + ); + + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); + targetCommand.destroy(); + await client.close(); +}); + +function assertContents(resources, expectedMessages) { + is( + resources.length, + expectedMessages.length, + "Number of the resources is correct" + ); + + for (let i = 0; i < expectedMessages.length; i++) { + const resource = resources[i]; + const message = resource.message.arguments[0]; + const expectedMessage = expectedMessages[i]; + is(message, expectedMessage, `The ${i}th content is correct`); + } +} + +function assertResources(resources, expectedResources) { + is( + resources.length, + expectedResources.length, + "Number of the resources is correct" + ); + + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + const expectedResource = expectedResources[i]; + Assert.strictEqual( + resource, + expectedResource, + `The ${i}th resource is correct` + ); + } +} + +function logMessages(browser, messages) { + return ContentTask.spawn(browser, { messages }, args => { + for (const message of args.messages) { + content.console.log(message); + } + }); +} + +async function triggerErrors(browser, errorScripts) { + for (const errorScript of errorScripts) { + await ContentTask.spawn(browser, errorScript, expr => { + const document = content.document; + const container = document.createElement("script"); + document.body.appendChild(container); + container.textContent = expr; + container.remove(); + }); + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_console_messages.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages.js new file mode 100644 index 0000000000..6f02cd5a77 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages.js @@ -0,0 +1,623 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CONSOLE_MESSAGE +// +// Reproduces assertions from: devtools/shared/webconsole/test/chrome/test_cached_messages.html +// And now more. Once we remove the console actor's startListeners in favor of watcher class +// We could remove that other old test. + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const IFRAME_URL = URL_ROOT_ORG_SSL + "fission_iframe.html"; + +add_task(async function () { + info("Execute test in top level document"); + await testTabConsoleMessagesResources(false); + await testTabConsoleMessagesResourcesWithIgnoreExistingResources(false); + + info("Execute test in an iframe document, possibly remote with fission"); + await testTabConsoleMessagesResources(true); + await testTabConsoleMessagesResourcesWithIgnoreExistingResources(true); +}); + +async function testTabConsoleMessagesResources(executeInIframe) { + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info( + "Log some messages *before* calling ResourceCommand.watchResources in order to " + + "assert the behavior of already existing messages." + ); + await logExistingMessages(tab.linkedBrowser, executeInIframe); + + const targetDocumentUrl = executeInIframe ? IFRAME_URL : FISSION_TEST_URL; + + let runtimeDoneResolve; + const expectedExistingCalls = + getExpectedExistingConsoleCalls(targetDocumentUrl); + const expectedRuntimeCalls = + getExpectedRuntimeConsoleCalls(targetDocumentUrl); + const onRuntimeDone = new Promise(resolve => (runtimeDoneResolve = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + is( + resource.resourceType, + resourceCommand.TYPES.CONSOLE_MESSAGE, + "Received a message" + ); + ok(resource.message, "message is wrapped into a message attribute"); + const isCachedMessage = !!expectedExistingCalls.length; + const expected = ( + isCachedMessage ? expectedExistingCalls : expectedRuntimeCalls + ).shift(); + checkConsoleAPICall(resource.message, expected); + is( + resource.isAlreadyExistingResource, + isCachedMessage, + "isAlreadyExistingResource has the expected value" + ); + + if (!expectedRuntimeCalls.length) { + runtimeDoneResolve(); + } + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + is( + expectedExistingCalls.length, + 0, + "Got the expected number of existing messages" + ); + + info( + "Now log messages *after* the call to ResourceCommand.watchResources and after having received all existing messages" + ); + await logRuntimeMessages(tab.linkedBrowser, executeInIframe); + + info("Waiting for all runtime messages"); + await onRuntimeDone; + + is( + expectedRuntimeCalls.length, + 0, + "Got the expected number of runtime messages" + ); + + targetCommand.destroy(); + await client.close(); +} + +async function testTabConsoleMessagesResourcesWithIgnoreExistingResources( + executeInIframe +) { + info("Test ignoreExistingResources option for console messages"); + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info( + "Check whether onAvailable will not be called with existing console messages" + ); + await logExistingMessages(tab.linkedBrowser, executeInIframe); + + const availableResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => availableResources.push(...resources), + ignoreExistingResources: true, + } + ); + is( + availableResources.length, + 0, + "onAvailable wasn't called for existing console messages" + ); + + info( + "Check whether onAvailable will be called with the future console messages" + ); + await logRuntimeMessages(tab.linkedBrowser, executeInIframe); + const targetDocumentUrl = executeInIframe ? IFRAME_URL : FISSION_TEST_URL; + const expectedRuntimeConsoleCalls = + getExpectedRuntimeConsoleCalls(targetDocumentUrl); + await waitUntil( + () => availableResources.length === expectedRuntimeConsoleCalls.length + ); + const expectedTargetFront = + executeInIframe && (isFissionEnabled() || isEveryFrameTargetEnabled()) + ? targetCommand + .getAllTargets([targetCommand.TYPES.FRAME]) + .find(target => target.url == IFRAME_URL) + : targetCommand.targetFront; + for (let i = 0; i < expectedRuntimeConsoleCalls.length; i++) { + const resource = availableResources[i]; + const { message, targetFront } = resource; + is( + targetFront, + expectedTargetFront, + "The targetFront property is the expected one" + ); + const expected = expectedRuntimeConsoleCalls[i]; + checkConsoleAPICall(message, expected); + is( + resource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false since we're ignoring existing resources" + ); + } + + targetCommand.destroy(); + await client.close(); +} + +async function logExistingMessages(browser, executeInIframe) { + let browsingContext = browser.browsingContext; + if (executeInIframe) { + browsingContext = await SpecialPowers.spawn( + browser, + [], + function frameScript() { + return content.document.querySelector("iframe").browsingContext; + } + ); + } + return evalInBrowsingContext(browsingContext, function pageScript() { + console.log("foobarBaz-log", undefined); + console.info("foobarBaz-info", null); + console.warn("foobarBaz-warn", document.body); + }); +} + +/** + * Helper function similar to spawn, but instead of executing the script + * as a Frame Script, with privileges and including test harness in stacktraces, + * execute the script as a regular page script, without privileges and without any + * preceding stack. + * + * @param {BrowsingContext} The browsing context into which the script should be evaluated + * @param {Function|String} The JS to execute in the browsing context + * + * @return {Promise} Which resolves once the JS is done executing in the page + */ +function evalInBrowsingContext(browsingContext, script) { + return SpecialPowers.spawn(browsingContext, [String(script)], expr => { + const document = content.document; + const scriptEl = document.createElement("script"); + document.body.appendChild(scriptEl); + // Force the immediate execution of the stringified JS function passed in `expr` + scriptEl.textContent = "new " + expr; + scriptEl.remove(); + }); +} + +// For both existing and runtime messages, we execute console API +// from a page script evaluated via evalInBrowsingContext. +// Records here the function used to execute the script in the page. +const EXPECTED_FUNCTION_NAME = "pageScript"; + +const NUMBER_REGEX = /^\d+$/; +// timeStamp are the result of a number in microsecond divided by 1000. +// so we can't expect a precise number of decimals, or even if there would +// be decimals at all. +const FRACTIONAL_NUMBER_REGEX = /^\d+(\.\d{1,3})?$/; + +function getExpectedExistingConsoleCalls(documentFilename) { + const defaultProperties = { + filename: documentFilename, + columnNumber: NUMBER_REGEX, + lineNumber: NUMBER_REGEX, + timeStamp: FRACTIONAL_NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + chromeContext: undefined, + counter: undefined, + prefix: undefined, + private: undefined, + stacktrace: undefined, + styles: undefined, + timer: undefined, + }; + + return [ + { + ...defaultProperties, + level: "log", + arguments: ["foobarBaz-log", { type: "undefined" }], + }, + { + ...defaultProperties, + level: "info", + arguments: ["foobarBaz-info", { type: "null" }], + }, + { + ...defaultProperties, + level: "warn", + arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }], + }, + ]; +} + +const longString = new Array(DevToolsServer.LONG_STRING_LENGTH + 2).join("a"); +function getExpectedRuntimeConsoleCalls(documentFilename) { + const defaultStackFrames = [ + // This is the usage of "new " + expr from `evalInBrowsingContext` + { + filename: documentFilename, + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + }, + ]; + + const defaultProperties = { + filename: documentFilename, + columnNumber: NUMBER_REGEX, + lineNumber: NUMBER_REGEX, + timeStamp: FRACTIONAL_NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + chromeContext: undefined, + counter: undefined, + prefix: undefined, + private: undefined, + stacktrace: undefined, + styles: undefined, + timer: undefined, + }; + + return [ + { + ...defaultProperties, + level: "log", + arguments: ["foobarBaz-log", { type: "undefined" }], + }, + { + ...defaultProperties, + level: "log", + arguments: ["Float from not a number: NaN"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["Float from string: 1.200000"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["Float from number: 1.300000"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["BigInt 123 and 456"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["message with ", "style"], + styles: ["color: blue;", "background: red; font-size: 2em;"], + }, + { + ...defaultProperties, + level: "info", + arguments: ["foobarBaz-info", { type: "null" }], + }, + { + ...defaultProperties, + level: "warn", + arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }], + }, + { + ...defaultProperties, + level: "debug", + arguments: [{ type: "null" }], + }, + { + ...defaultProperties, + level: "trace", + stacktrace: [ + { + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + }, + ...defaultStackFrames, + ], + }, + { + ...defaultProperties, + level: "dir", + arguments: [ + { + type: "object", + actor: /[a-z]/, + class: "HTMLDocument", + }, + { + type: "object", + actor: /[a-z]/, + class: "Location", + }, + ], + }, + { + ...defaultProperties, + level: "log", + arguments: [ + "foo", + { + type: "longString", + initial: longString.substring( + 0, + DevToolsServer.LONG_STRING_INITIAL_LENGTH + ), + length: longString.length, + actor: /[a-z]/, + }, + ], + }, + { + ...defaultProperties, + level: "count", + arguments: ["myCounter"], + counter: { + count: 1, + label: "myCounter", + }, + }, + { + ...defaultProperties, + level: "count", + arguments: ["myCounter"], + counter: { + count: 2, + label: "myCounter", + }, + }, + { + ...defaultProperties, + level: "count", + arguments: ["default"], + counter: { + count: 1, + label: "default", + }, + }, + { + ...defaultProperties, + level: "countReset", + arguments: ["myCounter"], + counter: { + count: 0, + label: "myCounter", + }, + }, + { + ...defaultProperties, + level: "countReset", + arguments: ["unknownCounter"], + counter: { + error: "counterDoesntExist", + label: "unknownCounter", + }, + }, + { + ...defaultProperties, + level: "time", + arguments: ["myTimer"], + timer: { + name: "myTimer", + }, + }, + { + ...defaultProperties, + level: "time", + arguments: ["myTimer"], + timer: { + name: "myTimer", + error: "timerAlreadyExists", + }, + }, + { + ...defaultProperties, + level: "timeLog", + arguments: ["myTimer"], + timer: { + name: "myTimer", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "timeEnd", + arguments: ["myTimer"], + timer: { + name: "myTimer", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "time", + arguments: ["default"], + timer: { + name: "default", + }, + }, + { + ...defaultProperties, + level: "timeLog", + arguments: ["default"], + timer: { + name: "default", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "timeEnd", + arguments: ["default"], + timer: { + name: "default", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "timeLog", + arguments: ["unknownTimer"], + timer: { + name: "unknownTimer", + error: "timerDoesntExist", + }, + }, + { + ...defaultProperties, + level: "timeEnd", + arguments: ["unknownTimer"], + timer: { + name: "unknownTimer", + error: "timerDoesntExist", + }, + }, + { + ...defaultProperties, + level: "error", + arguments: ["foobarBaz-asmjs-error", { type: "undefined" }], + + stacktrace: [ + { + filename: documentFilename, + functionName: "fromAsmJS", + }, + { + filename: documentFilename, + functionName: "inAsmJS2", + }, + { + filename: documentFilename, + functionName: "inAsmJS1", + }, + { + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + }, + ...defaultStackFrames, + ], + }, + { + ...defaultProperties, + level: "log", + filename: + "chrome://mochitests/content/browser/devtools/shared/commands/resource/tests/browser_resources_console_messages.js", + arguments: [ + { + type: "object", + actor: /[a-z]/, + class: "Restricted", + }, + ], + chromeContext: true, + }, + ]; +} + +async function logRuntimeMessages(browser, executeInIframe) { + let browsingContext = browser.browsingContext; + if (executeInIframe) { + browsingContext = await SpecialPowers.spawn( + browser, + [], + function frameScript() { + return content.document.querySelector("iframe").browsingContext; + } + ); + } + // First inject LONG_STRING_LENGTH in global scope it order to easily use it after + await evalInBrowsingContext( + browsingContext, + `function () {window.LONG_STRING_LENGTH = ${DevToolsServer.LONG_STRING_LENGTH};}` + ); + await evalInBrowsingContext(browsingContext, function pageScript() { + const _longString = new Array(window.LONG_STRING_LENGTH + 2).join("a"); + + console.log("foobarBaz-log", undefined); + + console.log("Float from not a number: %f", "foo"); + console.log("Float from string: %f", "1.2"); + console.log("Float from number: %f", 1.3); + console.log("BigInt %d and %i", 123n, 456n); + console.log( + "%cmessage with %cstyle", + "color: blue;", + "background: red; font-size: 2em;" + ); + + console.info("foobarBaz-info", null); + console.warn("foobarBaz-warn", document.documentElement); + console.debug(null); + console.trace(); + console.dir(document, location); + console.log("foo", _longString); + + console.count("myCounter"); + console.count("myCounter"); + console.count(); + console.countReset("myCounter"); + // will cause warnings because unknownCounter doesn't exist + console.countReset("unknownCounter"); + + console.time("myTimer"); + // will cause warning because myTimer already exist + console.time("myTimer"); + console.timeLog("myTimer"); + console.timeEnd("myTimer"); + console.time(); + console.timeLog(); + console.timeEnd(); + // // will cause warnings because unknownTimer doesn't exist + console.timeLog("unknownTimer"); + console.timeEnd("unknownTimer"); + + function fromAsmJS() { + console.error("foobarBaz-asmjs-error", undefined); + } + + (function (global, foreign) { + "use asm"; + function inAsmJS2() { + foreign.fromAsmJS(); + } + function inAsmJS1() { + inAsmJS2(); + } + return inAsmJS1; + })(null, { fromAsmJS })(); + }); + await SpecialPowers.spawn(browsingContext, [], function frameScript() { + const sandbox = new Cu.Sandbox(null, { invisibleToDebugger: true }); + const sandboxObj = sandbox.eval("new Object"); + content.console.log(sandboxObj); + }); +} + +// Copied from devtools/shared/webconsole/test/chrome/common.js +function checkConsoleAPICall(call, expected) { + is( + call.arguments?.length || 0, + expected.arguments?.length || 0, + "number of arguments" + ); + + checkObject(call, expected); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js new file mode 100644 index 0000000000..3d6fc697da --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js @@ -0,0 +1,190 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the resource command API around CONSOLE_MESSAGE when navigating +// tab and inner iframes to distinct origin/processes. + +const TEST_URL = URL_ROOT_COM_SSL + "doc_console.html"; +const TEST_IFRAME_URL = URL_ROOT_ORG_SSL + "doc_console_iframe.html"; +const TEST_DOMAIN = "https://example.org"; +add_task(async function () { + const START_URL = "data:text/html;charset=utf-8,foo"; + const tab = await addTab(START_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + await testCrossProcessTabNavigation(tab.linkedBrowser, resourceCommand); + await testCrossProcessIframeNavigation(tab.linkedBrowser, resourceCommand); + + targetCommand.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +}); + +async function testCrossProcessTabNavigation(browser, resourceCommand) { + info( + "Navigate the top level document from data: URI to a https document including remote iframes" + ); + + let doneResolve; + const messages = []; + const onConsoleLogsComplete = new Promise(resolve => (doneResolve = resolve)); + + const onAvailable = resources => { + messages.push(...resources); + if (messages.length == 2) { + doneResolve(); + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + + const onLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, TEST_URL); + await onLoaded; + + info("Wait for log message"); + await onConsoleLogsComplete; + + // messages are coming from different targets so the order isn't guaranteed + const topLevelMessageResource = messages.find(resource => + resource.message.filename.startsWith(URL_ROOT_COM_SSL) + ); + const iframeMessage = messages.find(resource => + resource.message.filename.startsWith("data:") + ); + + assertConsoleMessage(resourceCommand, topLevelMessageResource, { + targetFront: resourceCommand.targetCommand.targetFront, + messageText: "top-level document log", + }); + assertConsoleMessage(resourceCommand, iframeMessage, { + targetFront: isEveryFrameTargetEnabled + ? resourceCommand.targetCommand + .getAllTargets([resourceCommand.targetCommand.TYPES.FRAME]) + .find(t => t.url.startsWith("data:")) + : resourceCommand.targetCommand.targetFront, + messageText: "data url data log", + }); + + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); +} + +async function testCrossProcessIframeNavigation(browser, resourceCommand) { + info("Navigate an inner iframe from data: URI to a https remote URL"); + + let doneResolve; + const messages = []; + const onConsoleLogsComplete = new Promise(resolve => (doneResolve = resolve)); + + const onAvailable = resources => { + messages.push( + ...resources.filter( + r => !r.message.arguments[0].startsWith("[WORKER] started") + ) + ); + if (messages.length == 3) { + doneResolve(); + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + + // messages are coming from different targets so the order isn't guaranteed + const topLevelMessageResource = messages.find(resource => + resource.message.arguments[0].startsWith("top-level") + ); + const dataUrlMessageResource = messages.find(resource => + resource.message.arguments[0].startsWith("data url") + ); + + // Assert cached messages from the previous top document + assertConsoleMessage(resourceCommand, topLevelMessageResource, { + messageText: "top-level document log", + }); + assertConsoleMessage(resourceCommand, dataUrlMessageResource, { + messageText: "data url data log", + }); + + // Navigate the iframe to another origin/process + await SpecialPowers.spawn(browser, [TEST_IFRAME_URL], function (iframeUrl) { + const iframe = content.document.querySelector("iframe"); + iframe.src = iframeUrl; + }); + + info("Wait for log message"); + await onConsoleLogsComplete; + + // iframeTarget will be different if Fission is on or off + const iframeTarget = await getIframeTargetFront( + resourceCommand.targetCommand + ); + + const iframeMessageResource = messages.find(resource => + resource.message.arguments[0].endsWith("iframe log") + ); + assertConsoleMessage(resourceCommand, iframeMessageResource, { + messageText: `${TEST_DOMAIN} iframe log`, + targetFront: iframeTarget, + }); + + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); +} + +function assertConsoleMessage(resourceCommand, messageResource, expected) { + is( + messageResource.resourceType, + resourceCommand.TYPES.CONSOLE_MESSAGE, + "Resource is a console message" + ); + ok(messageResource.message, "message is wrapped into a message attribute"); + if (expected.targetFront) { + is( + messageResource.targetFront, + expected.targetFront, + "Message has the correct target front" + ); + } + is( + messageResource.message.arguments[0], + expected.messageText, + "The correct type of message" + ); +} + +async function getIframeTargetFront(targetCommand) { + // If Fission/EFT is enabled, the iframe will have a dedicated target, + // otherwise it will be debuggable via the top level target. + if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) { + return targetCommand.targetFront; + } + const frameTargets = targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + const browsingContextID = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + return content.document.querySelector("iframe").browsingContext.id; + } + ); + const iframeTarget = frameTargets.find(target => { + return target.browsingContextID == browsingContextID; + }); + ok(iframeTarget, "Found the iframe target front"); + return iframeTarget; +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js new file mode 100644 index 0000000000..4b10f1d2e4 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js @@ -0,0 +1,257 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CONSOLE_MESSAGE in workers + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document_workers.html"; +const WORKER_FILE = "test_worker.js"; +const IFRAME_FILE = `${URL_ROOT_ORG_SSL}fission_iframe_workers.html`; + +add_task(async function () { + // Set the following pref to false as it's the one that enables direct connection + // to the worker targets + await pushPref("dom.worker.console.dispatch_events_to_main_thread", false); + + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab, + { listenForWorkers: true } + ); + + info("Wait for the workers (from the main page and the iframe) to be ready"); + const targets = []; + await new Promise(resolve => { + const onAvailable = async ({ targetFront }) => { + targets.push(targetFront); + if (targets.length === 2) { + resolve(); + } + }; + targetCommand.watchTargets({ + types: [targetCommand.TYPES.WORKER], + onAvailable, + }); + }); + + // The worker logs a message right when it starts, containing its location, so we can + // assert that we get the logs from the worker spawned in the content page and from the + // worker spawned in the iframe. + info("Check that we receive the cached messages"); + + const resources = []; + const onAvailable = innerResources => { + for (const resource of innerResources) { + // Ignore resources from non worker targets + if (!resource.targetFront.isWorkerTarget) { + continue; + } + + resources.push(resource); + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + + is(resources.length, 2, "Got the expected number of existing messages"); + const startLogFromWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker` + ); + const startLogFromWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe` + ); + + checkStartWorkerLogMessage(startLogFromWorkerInMainPage, { + expectedUrl: `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker`, + isAlreadyExistingResource: true, + }); + checkStartWorkerLogMessage(startLogFromWorkerInIframe, { + expectedUrl: `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe`, + isAlreadyExistingResource: true, + }); + let messageCount = resources.length; + + info( + "Now log messages *after* the call to ResourceCommand.watchResources and after having received all existing messages" + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.wrappedJSObject.logMessageInWorker("live message from main page"); + + const iframe = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframe, [], () => { + content.wrappedJSObject.logMessageInWorker("live message from iframe"); + }); + }); + + // Wait until the 2 new logs are available + await waitUntil(() => resources.length === messageCount + 2); + const liveMessageFromWorkerInMainPage = resources.find( + ({ message }) => message.arguments[1] === "live message from main page" + ); + const liveMessageFromWorkerInIframe = resources.find( + ({ message }) => message.arguments[1] === "live message from iframe" + ); + + checkLogInWorkerMessage( + liveMessageFromWorkerInMainPage, + "live message from main page" + ); + + checkLogInWorkerMessage( + liveMessageFromWorkerInIframe, + "live message from iframe" + ); + + // update the current number of resources received + messageCount = resources.length; + + info("Now spawn new workers and log messages in main page and iframe"); + await SpecialPowers.spawn( + tab.linkedBrowser, + [WORKER_FILE], + async workerUrl => { + const spawnedWorker = new content.Worker(`${workerUrl}#spawned-worker`); + spawnedWorker.postMessage({ + type: "log-in-worker", + message: "live message in spawned worker from main page", + }); + + const iframe = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframe, [workerUrl], async innerWorkerUrl => { + const spawnedWorkerInIframe = new content.Worker( + `${innerWorkerUrl}#spawned-worker-in-iframe` + ); + spawnedWorkerInIframe.postMessage({ + type: "log-in-worker", + message: "live message in spawned worker from iframe", + }); + }); + } + ); + + info( + "Wait until the 4 new logs are available (the ones logged at worker creation + the ones from postMessage" + ); + await waitUntil( + () => resources.length === messageCount + 4, + `Couldn't get the expected number of resources (expected ${ + messageCount + 4 + }, got ${resources.length})` + ); + const startLogFromSpawnedWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker` + ); + const startLogFromSpawnedWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#spawned-worker-in-iframe` + ); + const liveMessageFromSpawnedWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === "live message in spawned worker from main page" + ); + const liveMessageFromSpawnedWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === "live message in spawned worker from iframe" + ); + + checkStartWorkerLogMessage(startLogFromSpawnedWorkerInMainPage, { + expectedUrl: `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker`, + }); + checkStartWorkerLogMessage(startLogFromSpawnedWorkerInIframe, { + expectedUrl: `${URL_ROOT_ORG_SSL}${WORKER_FILE}#spawned-worker-in-iframe`, + }); + checkLogInWorkerMessage( + liveMessageFromSpawnedWorkerInMainPage, + "live message in spawned worker from main page" + ); + checkLogInWorkerMessage( + liveMessageFromSpawnedWorkerInIframe, + "live message in spawned worker from iframe" + ); + // update the current number of resources received + messageCount = resources.length; + + info( + "Add a remote iframe on the same origin we already have an iframe and check we get the messages" + ); + await SpecialPowers.spawn( + tab.linkedBrowser, + [IFRAME_FILE], + async iframeUrl => { + const iframe = content.document.createElement("iframe"); + iframe.src = `${iframeUrl}?hashSuffix=in-second-iframe`; + content.document.body.append(iframe); + } + ); + + info("Wait until the new log is available"); + await waitUntil( + () => resources.length === messageCount + 1, + `Couldn't get the expected number of resources (expected ${ + messageCount + 1 + }, got ${resources.length})` + ); + const startLogFromWorkerInSecondIframe = resources[resources.length - 1]; + checkStartWorkerLogMessage(startLogFromWorkerInSecondIframe, { + expectedUrl: `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-second-iframe`, + }); + + targetCommand.destroy(); + await client.close(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +function checkStartWorkerLogMessage( + resource, + { expectedUrl, isAlreadyExistingResource = false } +) { + const { message } = resource; + const [firstArg, secondArg, thirdArg] = message.arguments; + is(firstArg, "[WORKER] started", "Got the expected first argument"); + is(secondArg, expectedUrl, "expected url was logged"); + is( + thirdArg?._grip?.class, + "DedicatedWorkerGlobalScope", + "the global scope was logged as expected" + ); + is( + resource.isAlreadyExistingResource, + isAlreadyExistingResource, + "Resource has expected value for isAlreadyExistingResource" + ); +} + +function checkLogInWorkerMessage(resource, expectedMessage) { + const { message } = resource; + const [firstArg, secondArg, thirdArg] = message.arguments; + is(firstArg, "[WORKER]", "Got the expected first argument"); + is(secondArg, expectedMessage, "expected message was logged"); + is( + thirdArg?._grip?.class, + "MessageEvent", + "the message event object was logged as expected" + ); + is( + resource.isAlreadyExistingResource, + false, + "Resource has expected value for isAlreadyExistingResource" + ); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_css_changes.js b/devtools/shared/commands/resource/tests/browser_resources_css_changes.js new file mode 100644 index 0000000000..22b11a8186 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_css_changes.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CSS_CHANGE. + +add_task(async function () { + // Open a test tab + const tab = await addTab( + "data:text/html,<body style='color: lime;'>CSS Changes</body>" + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // CSS_CHANGE watcher doesn't record modification made before watching, + // so we have to start watching before doing any DOM mutation. + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_CHANGE], { + onAvailable: () => {}, + }); + + const { walker } = await targetCommand.targetFront.getFront("inspector"); + const nodeList = await walker.querySelectorAll(walker.rootNode, "body"); + const body = (await nodeList.items())[0]; + const style = ( + await body.inspectorFront.pageStyle.getApplied(body, { + skipPseudo: false, + }) + )[0]; + + info( + "Check whether ResourceCommand catches CSS change that fired before starting to watch" + ); + await setProperty(style.rule, 0, "color", "black"); + + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_CHANGE], { + onAvailable: resources => availableResources.push(...resources), + }); + assertResource( + availableResources[0], + { index: 0, property: "color", value: "black" }, + { index: 0, property: "color", value: "lime" } + ); + + info( + "Check whether ResourceCommand catches CSS changes after the property was renamed and updated" + ); + + // RuleRewriter:apply will not support a simultaneous rename + setProperty. + // Doing so would send inconsistent arguments to StyleRuleActor:setRuleText, + // the CSS text for the rule will not match the list of modifications, which + // would desynchronize the Changes view. Thankfully this scenario should not + // happen when using the UI to update the rules. + await renameProperty(style.rule, 0, "color", "background-color"); + await waitUntil(() => availableResources.length === 2); + assertResource( + availableResources[1], + { index: 0, property: "background-color", value: "black" }, + { index: 0, property: "color", value: "black" } + ); + + await setProperty(style.rule, 0, "background-color", "pink"); + await waitUntil(() => availableResources.length === 3); + assertResource( + availableResources[2], + { index: 0, property: "background-color", value: "pink" }, + { index: 0, property: "background-color", value: "black" } + ); + + info("Check whether ResourceCommand catches CSS change of disabling"); + await setPropertyEnabled(style.rule, 0, "background-color", false); + await waitUntil(() => availableResources.length === 4); + assertResource(availableResources[3], null, { + index: 0, + property: "background-color", + value: "pink", + }); + + info("Check whether ResourceCommand catches CSS change of new property"); + await createProperty(style.rule, 1, "font-size", "100px"); + await waitUntil(() => availableResources.length === 5); + assertResource( + availableResources[4], + { index: 1, property: "font-size", value: "100px" }, + null + ); + + info("Check whether ResourceCommand sends all resources added in this test"); + const existingResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_CHANGE], { + onAvailable: resources => existingResources.push(...resources), + }); + await waitUntil(() => existingResources.length === 5); + is(availableResources[0], existingResources[0], "1st resource is correct"); + is(availableResources[1], existingResources[1], "2nd resource is correct"); + is(availableResources[2], existingResources[2], "3rd resource is correct"); + is(availableResources[3], existingResources[3], "4th resource is correct"); + is(availableResources[4], existingResources[4], "4th resource is correct"); + + targetCommand.destroy(); + await client.close(); +}); + +function assertResource(resource, expectedAddedChange, expectedRemovedChange) { + if (expectedAddedChange) { + is(resource.add.length, 1, "The number of added changes is correct"); + assertChange(resource.add[0], expectedAddedChange); + } else { + is(resource.add, null, "There is no added changes"); + } + + if (expectedRemovedChange) { + is(resource.remove.length, 1, "The number of removed changes is correct"); + assertChange(resource.remove[0], expectedRemovedChange); + } else { + is(resource.remove, null, "There is no removed changes"); + } +} + +function assertChange(change, expected) { + is(change.index, expected.index, "The index of change is correct"); + is(change.property, expected.property, "The property of change is correct"); + is(change.value, expected.value, "The value of change is correct"); +} + +async function setProperty(rule, index, property, value) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.setProperty(index, property, value, ""); + await modifications.apply(); +} + +async function renameProperty(rule, index, oldName, newName, value) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.renameProperty(index, oldName, newName); + await modifications.apply(); +} + +async function createProperty(rule, index, property, value) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.createProperty(index, property, value, "", true); + await modifications.apply(); +} + +async function setPropertyEnabled(rule, index, property, isEnabled) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.setPropertyEnabled(index, property, isEnabled); + await modifications.apply(); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_css_messages.js b/devtools/shared/commands/resource/tests/browser_resources_css_messages.js new file mode 100644 index 0000000000..1b4b56cd4f --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_css_messages.js @@ -0,0 +1,212 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CSS_MESSAGE +// Reproduces the CSS message assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html + +const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js"); + +// Create a simple server so we have a nice sourceName in the resources packets. +const httpServer = createTestHTTPServer(); +httpServer.registerPathHandler(`/test_css_messages.html`, (req, res) => { + res.setStatusLine(req.httpVersion, 200, "OK"); + res.write(`<meta charset=utf8> + <style> + html { + body { + color: bloup; + } + } + </style>Test CSS Messages`); +}); + +const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/test_css_messages.html`; + +add_task(async function () { + await testWatchingCssMessages(); + await testWatchingCachedCssMessages(); +}); + +async function testWatchingCssMessages() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + // Open a test tab + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const receivedMessages = []; + const { onAvailable, onAllMessagesReceived } = setupOnAvailableFunction( + targetCommand, + receivedMessages, + false + ); + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_MESSAGE], { + onAvailable, + }); + + info( + "Now log CSS warning *after* the call to ResourceCommand.watchResources and after " + + "having received the existing message" + ); + // We need to wait for the first CSS Warning as it is not a cached message; when we + // start watching, the `cssErrorReportingEnabled` is checked on the target docShell, and + // if it is false, we re-parse the stylesheets to get the messages. + await BrowserTestUtils.waitForCondition(() => receivedMessages.length === 1); + + info("Trigger a CSS Warning"); + triggerCSSWarning(tab); + + info("Waiting for all expected CSS messages to be received"); + await onAllMessagesReceived; + ok(true, "All the expected CSS messages were received"); + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} + +async function testWatchingCachedCssMessages() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + // Open a test tab + const tab = await addTab(TEST_URI); + + // By default, the CSS Parser does not emit warnings at all, for performance matter. + // Since we actually want the Parser to emit those messages _before_ we start listening + // for CSS messages, we need to set the cssErrorReportingEnabled flag on the docShell. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + content.docShell.cssErrorReportingEnabled = true; + }); + + // Setting the docShell flag only indicates to the Parser that from now on, it should + // emit warnings. But it does not automatically emit warnings for the existing CSS + // errors in the stylesheets. So here we reload the tab, which will make the Parser + // parse the stylesheets again, this time emitting warnings. + await reloadBrowser(); + // and trigger more CSS warnings + await triggerCSSWarning(tab); + + // At this point, all messages should be in the ConsoleService cache, and we can begin + // to watch and check that we do retrieve those messages. + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const receivedMessages = []; + const { onAvailable } = setupOnAvailableFunction( + targetCommand, + receivedMessages, + true + ); + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_MESSAGE], { + onAvailable, + }); + is(receivedMessages.length, 3, "Cached messages were retrieved as expected"); + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} + +function setupOnAvailableFunction( + targetCommand, + receivedMessages, + isAlreadyExistingResource +) { + // timeStamp are the result of a number in microsecond divided by 1000. + // so we can't expect a precise number of decimals, or even if there would + // be decimals at all. + const FRACTIONAL_NUMBER_REGEX = /^\d+(\.\d{1,3})?$/; + + // The expected messages are the CSS warnings: + // - one for the rule in the style element + // - two for the JS modified style we're doing in the test. + const expectedMessages = [ + { + pageError: { + errorMessage: /Expected color but found ‘bloup’/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: false, + warning: true, + }, + cssSelectors: ":is(html) body", + isAlreadyExistingResource, + }, + { + pageError: { + errorMessage: /Error in parsing value for ‘width’/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: false, + warning: true, + }, + isAlreadyExistingResource, + }, + { + pageError: { + errorMessage: /Error in parsing value for ‘height’/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: false, + warning: true, + }, + isAlreadyExistingResource, + }, + ]; + + let done; + const onAllMessagesReceived = new Promise(resolve => (done = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + const { pageError } = resource; + + is( + resource.targetFront, + targetCommand.targetFront, + "The targetFront property is the expected one" + ); + + if (!pageError.sourceName.includes("test_css_messages")) { + info(`Ignore error from unknown source: "${pageError.sourceName}"`); + continue; + } + + const index = receivedMessages.length; + receivedMessages.push(resource); + + info( + `checking received css message #${index}: ${pageError.errorMessage}` + ); + ok(pageError, "The resource has a pageError attribute"); + checkObject(resource, expectedMessages[index]); + + if (receivedMessages.length == expectedMessages.length) { + done(); + } + } + }; + return { onAvailable, onAllMessagesReceived }; +} + +/** + * Sets invalid values for width and height on the document's body style attribute. + */ +function triggerCSSWarning(tab) { + return ContentTask.spawn(tab.linkedBrowser, null, function frameScript() { + content.document.body.style.width = "red"; + content.document.body.style.height = "blue"; + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_css_registered_properties.js b/devtools/shared/commands/resource/tests/browser_resources_css_registered_properties.js new file mode 100644 index 0000000000..1429b55167 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_css_registered_properties.js @@ -0,0 +1,384 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CSS_REGISTERED_PROPERTIES. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const IFRAME_URL = `https://example.org/document-builder.sjs?html=${encodeURIComponent(` + <style> + @property --css-a { + syntax: "<color>"; + inherits: true; + initial-value: gold; + } + </style> + <script> + CSS.registerProperty({ + name: "--js-a", + syntax: "<length>", + inherits: true, + initialValue: "20px" + }); + </script> + <h1>iframe</h1> +`)}`; + +const TEST_URL = `https://example.org/document-builder.sjs?html= + <head> + <style> + @property --css-a { + syntax: "*"; + inherits: false; + } + + @property --css-b { + syntax: "<color>"; + inherits: true; + initial-value: tomato; + } + </style> + <script> + CSS.registerProperty({ + name: "--js-a", + syntax: "*", + inherits: false, + }); + CSS.registerProperty({ + name: "--js-b", + syntax: "<length>", + inherits: true, + initialValue: "10px" + }); + </script> + </head> + <h1>CSS_REGISTERED_PROPERTIES</h1> + <iframe src="${encodeURIComponent(IFRAME_URL)}"></iframe>`; + +add_task(async function () { + await pushPref("layout.css.properties-and-values.enabled", true); + const tab = await addTab(TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // Wait for targets + await targetCommand.startListening(); + const targets = []; + const onAvailable = ({ targetFront }) => targets.push(targetFront); + await targetCommand.watchTargets({ + types: [targetCommand.TYPES.FRAME], + onAvailable, + }); + await waitFor(() => targets.length === 2); + const [topLevelTarget, iframeTarget] = targets.sort((a, b) => + a.isTopLevel ? -1 : 1 + ); + + // Watching for new stylesheets shouldn't be + const stylesheets = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => stylesheets.push(...resources), + ignoreExistingResources: true, + }); + + info("Check that we get existing registered properties"); + const availableResources = []; + const updatedResources = []; + const destroyedResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES], + { + onAvailable: resources => availableResources.push(...resources), + onUpdated: resources => updatedResources.push(...resources), + onDestroyed: resources => destroyedResources.push(...resources), + } + ); + + is( + availableResources.length, + 6, + "The 6 existing registered properties where retrieved" + ); + + // Sort resources so we get them alphabetically ordered by their name, with the ones for + // the top level target displayed first. + availableResources.sort((a, b) => { + if (a.targetFront !== b.targetFront) { + return a.targetFront.isTopLevel ? -1 : 1; + } + return a.name < b.name ? -1 : 1; + }); + + assertResource(availableResources[0], { + name: "--css-a", + syntax: "*", + inherits: false, + initialValue: null, + fromJS: false, + targetFront: topLevelTarget, + }); + assertResource(availableResources[1], { + name: "--css-b", + syntax: "<color>", + inherits: true, + initialValue: "tomato", + fromJS: false, + targetFront: topLevelTarget, + }); + assertResource(availableResources[2], { + name: "--js-a", + syntax: "*", + inherits: false, + initialValue: null, + fromJS: true, + targetFront: topLevelTarget, + }); + assertResource(availableResources[3], { + name: "--js-b", + syntax: "<length>", + inherits: true, + initialValue: "10px", + fromJS: true, + targetFront: topLevelTarget, + }); + assertResource(availableResources[4], { + name: "--css-a", + syntax: "<color>", + inherits: true, + initialValue: "gold", + fromJS: false, + targetFront: iframeTarget, + }); + assertResource(availableResources[5], { + name: "--js-a", + syntax: "<length>", + inherits: true, + initialValue: "20px", + fromJS: true, + targetFront: iframeTarget, + }); + + info("Check that we didn't get notified about existing stylesheets"); + // wait a bit so we'd have the time to be notified about stylesheet resources + await wait(500); + is( + stylesheets.length, + 0, + "Watching for registered properties does not notify about existing stylesheets resources" + ); + + info("Check that we get properties from new stylesheets"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const s = content.document.createElement("style"); + s.textContent = ` + @property --css-c { + syntax: "<custom-ident>"; + inherits: true; + initial-value: custom; + } + + @property --css-d { + syntax: "big | bigger"; + inherits: true; + initial-value: big; + } + `; + content.document.head.append(s); + }); + + info("Wait for registered properties to be available"); + await waitFor(() => availableResources.length === 8); + ok(true, "Got notified about 2 new registered properties"); + assertResource(availableResources[6], { + name: "--css-c", + syntax: "<custom-ident>", + inherits: true, + initialValue: "custom", + fromJS: false, + targetFront: topLevelTarget, + }); + assertResource(availableResources[7], { + name: "--css-d", + syntax: "big | bigger", + inherits: true, + initialValue: "big", + fromJS: false, + targetFront: topLevelTarget, + }); + + info("Wait to be notified about the new stylesheet"); + await waitFor(() => stylesheets.length === 1); + ok(true, "we do get notified about stylesheets"); + + info( + "Check that we get notified about properties registered via CSS.registerProperty" + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.CSS.registerProperty({ + name: "--js-c", + syntax: "*", + inherits: false, + initialValue: 42, + }); + content.CSS.registerProperty({ + name: "--js-d", + syntax: "<color>#", + inherits: true, + initialValue: "blue,cyan", + }); + }); + + await waitFor(() => availableResources.length === 10); + ok(true, "Got notified about 2 new registered properties"); + assertResource(availableResources[8], { + name: "--js-c", + syntax: "*", + inherits: false, + initialValue: "42", + fromJS: true, + targetFront: topLevelTarget, + }); + assertResource(availableResources[9], { + name: "--js-d", + syntax: "<color>#", + inherits: true, + initialValue: "blue,cyan", + fromJS: true, + targetFront: topLevelTarget, + }); + + info( + "Check that we get notified about properties registered via CSS.registerProperty in iframe" + ); + const iframeBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); + + await SpecialPowers.spawn(iframeBrowsingContext, [], () => { + content.CSS.registerProperty({ + name: "--js-iframe", + syntax: "<color>#", + inherits: true, + initialValue: "red,salmon", + }); + }); + + await waitFor(() => availableResources.length === 11); + ok(true, "Got notified about 2 new registered properties"); + assertResource(availableResources[10], { + name: "--js-iframe", + syntax: "<color>#", + inherits: true, + initialValue: "red,salmon", + fromJS: true, + targetFront: iframeTarget, + }); + + info( + "Check that we get notified about destroyed properties when removing stylesheet" + ); + // sanity check + is(destroyedResources.length, 0, "No destroyed resources yet"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.document.querySelector("style").remove(); + }); + await waitFor(() => destroyedResources.length == 2); + ok(true, "We got notified about destroyed resources"); + destroyedResources.sort((a, b) => a < b); + is( + destroyedResources[0].resourceType, + ResourceCommand.TYPES.CSS_REGISTERED_PROPERTIES, + "resource type is correct" + ); + is( + destroyedResources[0].resourceId, + `${topLevelTarget.actorID}:css-registered-property:--css-a`, + "expected css property was destroyed" + ); + is( + destroyedResources[1].resourceType, + ResourceCommand.TYPES.CSS_REGISTERED_PROPERTIES, + "resource type is correct" + ); + is( + destroyedResources[1].resourceId, + `${topLevelTarget.actorID}:css-registered-property:--css-b`, + "expected css property was destroyed" + ); + + info( + "Check that we get notified about updated properties when modifying stylesheet" + ); + is(updatedResources.length, 0, "No updated resources yet"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.document.querySelector("style").textContent = ` + /* not updated */ + @property --css-c { + syntax: "<custom-ident>"; + inherits: true; + initial-value: custom; + } + + @property --css-d { + syntax: "big | bigger"; + inherits: true; + /* only change initial value (was big) */ + initial-value: bigger; + } + + /* add a new property */ + @property --css-e { + syntax: "<color>"; + inherits: false; + initial-value: green; + } + `; + }); + await waitFor(() => updatedResources.length === 1); + ok(true, "One property was updated"); + assertResource(updatedResources[0].resource, { + name: "--css-d", + syntax: "big | bigger", + inherits: true, + initialValue: "bigger", + fromJS: false, + targetFront: topLevelTarget, + }); + + await waitFor(() => availableResources.length === 12); + ok(true, "We got notified about the new property"); + assertResource(availableResources.at(-1), { + name: "--css-e", + syntax: "<color>", + inherits: false, + initialValue: "green", + fromJS: false, + targetFront: topLevelTarget, + }); + + await client.close(); +}); + +async function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.CSS_REGISTERED_PROPERTIES, + "Resource type is correct" + ); + is(resource.name, expected.name, "name is correct"); + is(resource.syntax, expected.syntax, "syntax is correct"); + is(resource.inherits, expected.inherits, "inherits is correct"); + is(resource.initialValue, expected.initialValue, "initialValue is correct"); + is(resource.fromJS, expected.fromJS, "fromJS is correct"); + is( + resource.targetFront, + expected.targetFront, + "resource is associated with expected target" + ); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_document_events.js b/devtools/shared/commands/resource/tests/browser_resources_document_events.js new file mode 100644 index 0000000000..4692cba1ed --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_document_events.js @@ -0,0 +1,720 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around DOCUMENT_EVENT + +add_task(async function () { + await testDocumentEventResources(); + await testDocumentEventResourcesWithIgnoreExistingResources(); + await testDomCompleteWithOverloadedConsole(); + await testIframeNavigation(); + await testBfCacheNavigation(); + await testDomCompleteWithWindowStop(); + await testCrossOriginNavigation(); +}); + +async function testDocumentEventResources() { + info("Test ResourceCommand for DOCUMENT_EVENT"); + + // Open a test tab + const title = "DocumentEventsTitle"; + const url = `data:text/html,<title>${title}</title>Document Events`; + const tab = await addTab(url); + + const listener = new ResourceListener(); + const { commands } = await initResourceCommand(tab); + + info( + "Check whether the document events are fired correctly even when the document was already loaded" + ); + const onLoadingAtInit = listener.once("dom-loading"); + const onInteractiveAtInit = listener.once("dom-interactive"); + const onCompleteAtInit = listener.once("dom-complete"); + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: parameters => listener.dispatch(parameters), + } + ); + await assertPromises( + commands, + // targetBeforeNavigation is only used when there is a will-navigate and a navigate, but there is none here + null, + // As we started watching on an already loaded document, and no navigation happened since we called watchResources, + // we don't have any will-navigate event + null, + onLoadingAtInit, + onInteractiveAtInit, + onCompleteAtInit + ); + ok( + true, + "Document events are fired even when the document was already loaded" + ); + let domLoadingResource = await onLoadingAtInit; + + is( + domLoadingResource.url, + url, + `resource ${domLoadingResource.name} has expected url` + ); + is( + domLoadingResource.title, + undefined, + `resource ${domLoadingResource.name} does not have a title property` + ); + + let domInteractiveResource = await onInteractiveAtInit; + is( + domInteractiveResource.url, + url, + `resource ${domInteractiveResource.name} has expected url` + ); + is( + domInteractiveResource.title, + title, + `resource ${domInteractiveResource.name} has expected title` + ); + let domCompleteResource = await onCompleteAtInit; + is( + domCompleteResource.url, + undefined, + `resource ${domCompleteResource.name} does not have a url property` + ); + is( + domCompleteResource.title, + undefined, + `resource ${domCompleteResource.name} does not have a title property` + ); + + info("Check whether the document events are fired correctly when reloading"); + const onWillNavigate = listener.once("will-navigate"); + const onLoadingAtReloaded = listener.once("dom-loading"); + const onInteractiveAtReloaded = listener.once("dom-interactive"); + const onCompleteAtReloaded = listener.once("dom-complete"); + const targetBeforeNavigation = commands.targetCommand.targetFront; + gBrowser.reloadTab(tab); + await assertPromises( + commands, + targetBeforeNavigation, + onWillNavigate, + onLoadingAtReloaded, + onInteractiveAtReloaded, + onCompleteAtReloaded + ); + ok(true, "Document events are fired after reloading"); + + domLoadingResource = await onLoadingAtReloaded; + is( + domLoadingResource.url, + url, + `resource ${domLoadingResource.name} has expected url after reloading` + ); + is( + domLoadingResource.title, + undefined, + `resource ${domLoadingResource.name} does not have a title property after reloading` + ); + + domInteractiveResource = await onInteractiveAtInit; + is( + domInteractiveResource.url, + url, + `resource ${domInteractiveResource.name} has url property after reloading` + ); + is( + domInteractiveResource.title, + title, + `resource ${domInteractiveResource.name} has expected title after reloading` + ); + domCompleteResource = await onCompleteAtInit; + is( + domCompleteResource.url, + undefined, + `resource ${domCompleteResource.name} does not have a url property after reloading` + ); + is( + domCompleteResource.title, + undefined, + `resource ${domCompleteResource.name} does not have a title property after reloading` + ); + + await commands.destroy(); +} + +async function testDocumentEventResourcesWithIgnoreExistingResources() { + info("Test ignoreExistingResources option for DOCUMENT_EVENT"); + + const tab = await addTab("data:text/html,Document Events"); + + const { commands } = await initResourceCommand(tab); + + info("Check whether the existing document events will not be fired"); + const documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => documentEvents.push(...resources), + ignoreExistingResources: true, + } + ); + is(documentEvents.length, 0, "Existing document events are not fired"); + + info("Check whether the future document events are fired"); + const targetBeforeNavigation = commands.targetCommand.targetFront; + gBrowser.reloadTab(tab); + info( + "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" + ); + await waitFor(() => documentEvents.length === 4); + assertEvents({ commands, targetBeforeNavigation, documentEvents }); + + await commands.destroy(); +} + +async function testIframeNavigation() { + info("Test iframe navigations for DOCUMENT_EVENT"); + + const tab = await addTab( + 'https://example.com/document-builder.sjs?html=<iframe src="https://example.net/document-builder.sjs?html=net"></iframe>' + ); + const secondPageUrl = "https://example.org/document-builder.sjs?html=org"; + + const { commands } = await initResourceCommand(tab); + + let documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => documentEvents.push(...resources), + } + ); + let iframeTarget; + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + is( + documentEvents.length, + 6, + "With fission/EFT, we get two targets and two sets of events: dom-loading, dom-interactive, dom-complete" + ); + [, iframeTarget] = await commands.targetCommand.getAllTargets([ + commands.targetCommand.TYPES.FRAME, + ]); + // Filter out each target events as their order to be random between the two targets + const topTargetEvents = documentEvents.filter( + r => r.targetFront == commands.targetCommand.targetFront + ); + const iframeTargetEvents = documentEvents.filter( + r => r.targetFront != commands.targetCommand.targetFront + ); + assertEvents({ + commands, + documentEvents: [null /* no will-navigate */, ...topTargetEvents], + }); + assertEvents({ + commands, + documentEvents: [null /* no will-navigate */, ...iframeTargetEvents], + expectedTargetFront: iframeTarget, + }); + } else { + assertEvents({ + commands, + documentEvents: [null /* no will-navigate */, ...documentEvents], + }); + } + + info("Navigate the iframe to another process (if fission is enabled)"); + documentEvents = []; + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [secondPageUrl], + function (url) { + const iframe = content.document.querySelector("iframe"); + iframe.src = url; + } + ); + + // We are switching to a new target only when fission is enabled... + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + await waitFor(() => documentEvents.length >= 3); + is( + documentEvents.length, + 3, + "With fission/EFT, we switch to a new target and get: dom-loading, dom-interactive, dom-complete (but no will-navigate as that's only for the top BrowsingContext)" + ); + const [, newIframeTarget] = await commands.targetCommand.getAllTargets([ + commands.targetCommand.TYPES.FRAME, + ]); + assertEvents({ + commands, + targetBeforeNavigation: iframeTarget, + documentEvents: [null /* no will-navigate */, ...documentEvents], + expectedTargetFront: newIframeTarget, + expectedNewURI: secondPageUrl, + }); + } else { + // Wait for some time in order to let a chance to receive some unexpected events + await wait(250); + is( + documentEvents.length, + 0, + "If fission is disabled, we navigate within the same process, we get no new target and no new resource" + ); + } + + await commands.destroy(); +} + +function isBfCacheInParentEnabled() { + return ( + Services.appinfo.sessionHistoryInParent && + Services.prefs.getBoolPref("fission.bfcacheInParent", false) + ); +} + +async function testBfCacheNavigation() { + info("Test bfcache navigations for DOCUMENT_EVENT"); + + info("Open a first document and navigate to a second one"); + const firstLocation = "data:text/html,<title>first</title>first page"; + const secondLocation = "data:text/html,<title>second</title>second page"; + const tab = await addTab(firstLocation); + const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + secondLocation + ); + await onLoaded; + + const { commands } = await initResourceCommand(tab); + + const documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => { + documentEvents.push(...resources); + }, + ignoreExistingResources: true, + } + ); + // Wait for some time for extra safety + await wait(250); + is(documentEvents.length, 0, "Existing document events are not fired"); + + info("Navigate back to the first page"); + const onSwitched = commands.targetCommand.once("switched-target"); + const targetBeforeNavigation = commands.targetCommand.targetFront; + gBrowser.goBack(); + + // We are switching to a new target only when fission/EFT is enabled... + if ( + (isFissionEnabled() || isEveryFrameTargetEnabled()) && + isBfCacheInParentEnabled() + ) { + await onSwitched; + } + + info( + "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" + ); + await waitFor(() => documentEvents.length >= 4); + /* Ignore will-navigate timestamp as all other DOCUMENT_EVENTS will be set at the original load date, + which is when we loaded from the network, and not when we loaded from bfcache */ + assertEvents({ + commands, + targetBeforeNavigation, + documentEvents, + ignoreWillNavigateTimestamp: true, + }); + + // Wait for some time in order to let a chance to have duplicated dom-loading events + await wait(250); + + is( + documentEvents.length, + 4, + "There is no duplicated event and only the 4 expected DOCUMENT_EVENT states" + ); + const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] = + documentEvents; + + is( + willNavigateEvent.name, + "will-navigate", + "The first DOCUMENT_EVENT is will-navigate" + ); + is( + loadingEvent.name, + "dom-loading", + "The second DOCUMENT_EVENT is dom-loading" + ); + is( + interactiveEvent.name, + "dom-interactive", + "The third DOCUMENT_EVENT is dom-interactive" + ); + is( + completeEvent.name, + "dom-complete", + "The fourth DOCUMENT_EVENT is dom-complete" + ); + + is( + loadingEvent.url, + firstLocation, + `resource ${loadingEvent.name} has expected url after navigation back` + ); + is( + loadingEvent.title, + undefined, + `resource ${loadingEvent.name} does not have a title property after navigating back` + ); + + is( + interactiveEvent.url, + firstLocation, + `resource ${interactiveEvent.name} has expected url property after navigating back` + ); + is( + interactiveEvent.title, + "first", + `resource ${interactiveEvent.name} has expected title after navigating back` + ); + + is( + completeEvent.url, + undefined, + `resource ${completeEvent.name} does not have a url property after navigating back` + ); + is( + completeEvent.title, + undefined, + `resource ${completeEvent.name} does not have a title property after navigating back` + ); + + await commands.destroy(); +} + +async function testCrossOriginNavigation() { + info("Test cross origin navigations for DOCUMENT_EVENT"); + + const tab = await addTab("https://example.com/document-builder.sjs?html=com"); + + const { commands } = await initResourceCommand(tab); + + const documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => documentEvents.push(...resources), + ignoreExistingResources: true, + } + ); + // Wait for some time for extra safety + await wait(250); + is(documentEvents.length, 0, "Existing document events are not fired"); + + info("Navigate to another process"); + const onSwitched = commands.targetCommand.once("switched-target"); + const netUrl = + "https://example.net/document-builder.sjs?html=<head><title>titleNet</title></head>net"; + const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + const targetBeforeNavigation = commands.targetCommand.targetFront; + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, netUrl); + await onLoaded; + + // We are switching to a new target only when fission is enabled... + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + await onSwitched; + } + + info( + "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" + ); + await waitFor(() => documentEvents.length >= 4); + assertEvents({ commands, targetBeforeNavigation, documentEvents }); + + // Wait for some time in order to let a chance to have duplicated dom-loading events + await wait(250); + + is( + documentEvents.length, + 4, + "There is no duplicated event and only the 4 expected DOCUMENT_EVENT states" + ); + const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] = + documentEvents; + + is( + willNavigateEvent.name, + "will-navigate", + "The first DOCUMENT_EVENT is will-navigate" + ); + is( + loadingEvent.name, + "dom-loading", + "The second DOCUMENT_EVENT is dom-loading" + ); + is( + interactiveEvent.name, + "dom-interactive", + "The third DOCUMENT_EVENT is dom-interactive" + ); + is( + completeEvent.name, + "dom-complete", + "The fourth DOCUMENT_EVENT is dom-complete" + ); + + is( + loadingEvent.url, + encodeURI(netUrl), + `resource ${loadingEvent.name} has expected url after reloading` + ); + is( + loadingEvent.title, + undefined, + `resource ${loadingEvent.name} does not have a title property after reloading` + ); + + is( + interactiveEvent.url, + encodeURI(netUrl), + `resource ${interactiveEvent.name} has expected url property after reloading` + ); + is( + interactiveEvent.title, + "titleNet", + `resource ${interactiveEvent.name} has expected title after reloading` + ); + + is( + completeEvent.url, + undefined, + `resource ${completeEvent.name} does not have a url property after reloading` + ); + is( + completeEvent.title, + undefined, + `resource ${completeEvent.name} does not have a title property after reloading` + ); + + await commands.destroy(); +} + +async function testDomCompleteWithOverloadedConsole() { + info("Test dom-complete with an overloaded console object"); + + const tab = await addTab( + "data:text/html,<script>window.console = {};</script>" + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check that all DOCUMENT_EVENTS are fired for the already loaded page"); + const documentEvents = []; + await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { + onAvailable: resources => documentEvents.push(...resources), + }); + is(documentEvents.length, 3, "Existing document events are fired"); + + const domComplete = documentEvents[2]; + is(domComplete.name, "dom-complete", "the last resource is the dom-complete"); + is( + domComplete.hasNativeConsoleAPI, + false, + "the console object is reported to be overloaded" + ); + + targetCommand.destroy(); + await client.close(); +} + +async function testDomCompleteWithWindowStop() { + info("Test dom-complete with a page calling window.stop()"); + + const tab = await addTab("data:text/html,foo"); + + const { commands, client, resourceCommand, targetCommand } = + await initResourceCommand(tab); + + info("Check that all DOCUMENT_EVENTS are fired for the already loaded page"); + let documentEvents = []; + await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { + onAvailable: resources => documentEvents.push(...resources), + }); + is(documentEvents.length, 3, "Existing document events are fired"); + documentEvents = []; + + const html = `<!DOCTYPE html><html> + <head> + <title>stopped page</title> + <script>window.stop();</script> + </head> + <body>Page content that shouldn't be displayed</body> +</html>`; + const secondLocation = "data:text/html," + encodeURIComponent(html); + const targetBeforeNavigation = commands.targetCommand.targetFront; + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + secondLocation + ); + info( + "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" + ); + await waitFor(() => documentEvents.length === 4); + + assertEvents({ commands, targetBeforeNavigation, documentEvents }); + + targetCommand.destroy(); + await client.close(); +} + +async function assertPromises( + commands, + targetBeforeNavigation, + onWillNavigate, + onLoading, + onInteractive, + onComplete +) { + const willNavigateEvent = await onWillNavigate; + const loadingEvent = await onLoading; + const interactiveEvent = await onInteractive; + const completeEvent = await onComplete; + assertEvents({ + commands, + targetBeforeNavigation, + documentEvents: [ + willNavigateEvent, + loadingEvent, + interactiveEvent, + completeEvent, + ], + }); +} + +function assertEvents({ + commands, + targetBeforeNavigation, + documentEvents, + expectedTargetFront = commands.targetCommand.targetFront, + expectedNewURI = gBrowser.selectedBrowser.currentURI.spec, + ignoreWillNavigateTimestamp = false, +}) { + const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] = + documentEvents; + if (willNavigateEvent) { + is(willNavigateEvent.name, "will-navigate", "Received the will-navigate"); + is( + willNavigateEvent.newURI, + expectedNewURI, + "will-navigate newURI is set to the current tab new location" + ); + } + is( + loadingEvent.name, + "dom-loading", + "loading received in the exepected order" + ); + is( + interactiveEvent.name, + "dom-interactive", + "interactive received in the expected order" + ); + is(completeEvent.name, "dom-complete", "complete received last"); + + if (willNavigateEvent) { + is( + typeof willNavigateEvent.time, + "number", + `Type of time attribute for will-navigate event is correct (${willNavigateEvent.time})` + ); + } + is( + typeof loadingEvent.time, + "number", + `Type of time attribute for loading event is correct (${loadingEvent.time})` + ); + is( + typeof interactiveEvent.time, + "number", + `Type of time attribute for interactive event is correct (${interactiveEvent.time})` + ); + is( + typeof completeEvent.time, + "number", + `Type of time attribute for complete event is correct (${completeEvent.time})` + ); + + if (willNavigateEvent && !ignoreWillNavigateTimestamp) { + Assert.lessOrEqual( + willNavigateEvent.time, + loadingEvent.time, + `Timestamp for dom-loading event is greater than will-navigate event (${willNavigateEvent.time} <= ${loadingEvent.time})` + ); + } + Assert.lessOrEqual( + loadingEvent.time, + interactiveEvent.time, + `Timestamp for interactive event is greater than loading event (${loadingEvent.time} <= ${interactiveEvent.time})` + ); + Assert.lessOrEqual( + interactiveEvent.time, + completeEvent.time, + `Timestamp for complete event is greater than interactive event (${interactiveEvent.time} <= ${completeEvent.time}).` + ); + + if (willNavigateEvent) { + // If we switched to a new target, this target will be different from currentTargetFront. + // This only happen if we navigate to another process or if server target switching is enabled. + is( + willNavigateEvent.targetFront, + targetBeforeNavigation, + "will-navigate target was the one before the navigation" + ); + } + is( + loadingEvent.targetFront, + expectedTargetFront, + "loading target is the expected one" + ); + is( + interactiveEvent.targetFront, + expectedTargetFront, + "interactive target is the expected one" + ); + is( + completeEvent.targetFront, + expectedTargetFront, + "complete target is the expected one" + ); + + is( + completeEvent.hasNativeConsoleAPI, + true, + "None of the tests (except the dedicated one) overload the console object" + ); +} + +class ResourceListener { + _listeners = new Map(); + + dispatch(resources) { + for (const resource of resources) { + const resolve = this._listeners.get(resource.name); + if (resolve) { + resolve(resource); + this._listeners.delete(resource.name); + } + } + } + + once(resourceName) { + return new Promise(r => this._listeners.set(resourceName, r)); + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_error_messages.js b/devtools/shared/commands/resource/tests/browser_resources_error_messages.js new file mode 100644 index 0000000000..6f94266e4c --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_error_messages.js @@ -0,0 +1,877 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around ERROR_MESSAGE +// Reproduces assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html + +// Create a simple server so we have a nice sourceName in the resources packets. +const httpServer = createTestHTTPServer(); +httpServer.registerPathHandler(`/test_page_errors.html`, (req, res) => { + res.setStatusLine(req.httpVersion, 200, "OK"); + res.write(`<!DOCTYPE html><meta charset=utf8>Test Error Messages`); +}); + +const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/test_page_errors.html`; + +add_task(async function () { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + await testErrorMessagesResources(); + await testErrorMessagesResourcesWithIgnoreExistingResources(); +}); + +async function testErrorMessagesResources() { + // Open a test tab + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const receivedMessages = []; + // The expected messages are the errors, twice (once for cached messages, once for live messages) + const expectedMessages = Array.from(expectedPageErrors.values()).concat( + Array.from(expectedPageErrors.values()) + ); + + info( + "Log some errors *before* calling ResourceCommand.watchResources in order to assert" + + " the behavior of already existing messages." + ); + await triggerErrors(tab); + + let done; + const onAllErrorReceived = new Promise(resolve => (done = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + const { pageError } = resource; + + is( + resource.targetFront, + targetCommand.targetFront, + "The targetFront property is the expected one" + ); + + if (!pageError.sourceName.includes("test_page_errors")) { + info(`Ignore error from unknown source: "${pageError.sourceName}"`); + continue; + } + + const index = receivedMessages.length; + receivedMessages.push(resource); + + const isAlreadyExistingResource = + receivedMessages.length <= expectedPageErrors.size; + is( + resource.isAlreadyExistingResource, + isAlreadyExistingResource, + "isAlreadyExistingResource has expected value" + ); + + info(`checking received page error #${index}: ${pageError.errorMessage}`); + ok(pageError, "The resource has a pageError attribute"); + checkPageErrorResource(pageError, expectedMessages[index]); + + if (receivedMessages.length == expectedMessages.length) { + done(); + } + } + }; + + await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable, + }); + + await BrowserTestUtils.waitForCondition( + () => receivedMessages.length === expectedPageErrors.size + ); + + info( + "Now log errors *after* the call to ResourceCommand.watchResources and after having" + + " received all existing messages" + ); + await triggerErrors(tab); + + info("Waiting for all expected errors to be received"); + await onAllErrorReceived; + ok(true, "All the expected errors were received"); + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} + +async function testErrorMessagesResourcesWithIgnoreExistingResources() { + info("Test ignoreExistingResources option for ERROR_MESSAGE"); + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info( + "Check whether onAvailable will not be called with existing error messages" + ); + await triggerErrors(tab); + + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable: resources => availableResources.push(...resources), + ignoreExistingResources: true, + }); + is( + availableResources.length, + 0, + "onAvailable wasn't called for existing error messages" + ); + + info( + "Check whether onAvailable will be called with the future error messages" + ); + await triggerErrors(tab); + + const expectedMessages = Array.from(expectedPageErrors.values()); + await waitUntil(() => availableResources.length === expectedMessages.length); + for (let i = 0; i < expectedMessages.length; i++) { + const resource = availableResources[i]; + const { pageError } = resource; + const expected = expectedMessages[i]; + checkPageErrorResource(pageError, expected); + is( + resource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is set to false for live messages" + ); + } + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} + +/** + * Triggers all the errors in the content page. + */ +async function triggerErrors(tab) { + for (const [expression, expected] of expectedPageErrors.entries()) { + if ( + !expected[noUncaughtException] && + !Services.appinfo.browserTabsRemoteAutostart + ) { + expectUncaughtException(); + } + + await ContentTask.spawn( + tab.linkedBrowser, + expression, + function frameScript(expr) { + const document = content.document; + const scriptEl = document.createElement("script"); + scriptEl.textContent = expr; + document.body.appendChild(scriptEl); + } + ); + + if (expected.isPromiseRejection) { + // Wait a bit after an uncaught promise rejection error, as they are not emitted + // right away. + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(res => setTimeout(res, 10)); + } + } +} + +function checkPageErrorResource(pageErrorResource, expected) { + // Let's remove test harness related frames in stacktrace + const clonedPageErrorResource = { ...pageErrorResource }; + if (clonedPageErrorResource.stacktrace) { + const index = clonedPageErrorResource.stacktrace.findIndex(frame => + frame.filename.startsWith("resource://testing-common/content-task.js") + ); + if (index > -1) { + clonedPageErrorResource.stacktrace = + clonedPageErrorResource.stacktrace.slice(0, index); + } + } + checkObject(clonedPageErrorResource, expected); +} + +const noUncaughtException = Symbol(); +const NUMBER_REGEX = /^\d+$/; +// timeStamp are the result of a number in microsecond divided by 1000. +// so we can't expect a precise number of decimals, or even if there would +// be decimals at all. +const FRACTIONAL_NUMBER_REGEX = /^\d+(\.\d{1,3})?$/; + +const mdnUrl = path => + `https://developer.mozilla.org/${path}?utm_source=mozilla&utm_medium=firefox-console-errors&utm_campaign=default`; + +const expectedPageErrors = new Map([ + [ + "document.doTheImpossible();", + { + errorMessage: /doTheImpossible/, + errorMessageName: "JSMSG_NOT_FUNCTION", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Not_a_function" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 10, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "(42).toString(0);", + { + errorMessage: /radix/, + errorMessageName: "JSMSG_BAD_RADIX", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Bad_radix"), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 6, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "'use strict'; (Object.freeze({name: 'Elsa', score: 157})).score = 0;", + { + errorMessage: /read.only/, + errorMessageName: "JSMSG_READ_ONLY", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 23, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "([]).length = -1", + { + errorMessage: /array length/, + errorMessageName: "JSMSG_BAD_ARRAY_LENGTH", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Invalid_array_length" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 2, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "'abc'.repeat(-1);", + { + errorMessage: /repeat count.*non-negative/, + errorMessageName: "JSMSG_NEGATIVE_REPETITION_COUNT", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Negative_repetition_count" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: "self-hosted", + sourceId: null, + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + functionName: "repeat", + }, + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "'a'.repeat(2e28);", + { + errorMessage: /repeat count.*less than infinity/, + errorMessageName: "JSMSG_RESULTING_STRING_TOO_LARGE", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Resulting_string_too_large" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: "self-hosted", + sourceId: null, + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + functionName: "repeat", + }, + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 5, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "77.1234.toExponential(-1);", + { + errorMessage: /out of range/, + errorMessageName: "JSMSG_PRECISION_RANGE", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Precision_range" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 9, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "function a() { return; 1 + 1; }", + { + errorMessage: /unreachable code/, + errorMessageName: "JSMSG_STMT_AFTER_RETURN", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: false, + warning: true, + info: false, + sourceId: null, + lineText: "function a() { return; 1 + 1; }", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Stmt_after_return" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: null, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "{let a, a;}", + { + errorMessage: /redeclaration of/, + errorMessageName: "JSMSG_REDECLARED_VAR", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + sourceId: null, + lineText: "{let a, a;}", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Redeclared_parameter" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [], + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + notes: [ + { + messageBody: /Previously declared at line/, + frame: { + source: /test_page_errors/, + }, + }, + ], + }, + ], + [ + `var error = new TypeError("abc"); + error.name = "MyError"; + error.message = "here"; + throw error`, + { + errorMessage: /MyError: here/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: undefined, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 13, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "DOMTokenList.prototype.contains.call([])", + { + errorMessage: /does not implement interface/, + errorMessageName: "MSG_METHOD_THIS_DOES_NOT_IMPLEMENT_INTERFACE", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: undefined, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 33, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + ` + function promiseThrow() { + var error2 = new TypeError("abc"); + error2.name = "MyPromiseError"; + error2.message = "here2"; + return Promise.reject(error2); + } + promiseThrow()`, + { + errorMessage: /MyPromiseError: here2/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: undefined, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + sourceId: null, + lineNumber: 6, + columnNumber: 24, + functionName: "promiseThrow", + }, + { + filename: /test_page_errors\.html/, + sourceId: null, + lineNumber: 8, + columnNumber: 7, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: true, + isForwardedFromContentProcess: false, + [noUncaughtException]: true, + }, + ], + [ + // Error with a cause + `var originalError = new TypeError("abc"); + var error = new Error("something went wrong", { cause: originalError }) + throw error`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 2, + columnNumber: 19, + functionName: null, + }, + ], + exception: { + preview: { + cause: { + class: "TypeError", + preview: { + message: "abc", + }, + }, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with a cause chain + `var a = new Error("err-a"); + var b = new Error("err-b", { cause: a }); + var c = new Error("err-c", { cause: b }); + var d = new Error("err-d", { cause: c }); + throw d`, + { + errorMessage: /Error: err-d/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 4, + columnNumber: 14, + functionName: null, + }, + ], + exception: { + preview: { + cause: { + class: "Error", + preview: { + message: "err-c", + cause: { + class: "Error", + preview: { + message: "err-b", + cause: { + class: "Error", + preview: { + message: "err-a", + }, + }, + }, + }, + }, + }, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with a null cause + `throw new Error("something went wrong", { cause: null })`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + exception: { + preview: { + cause: { + type: "null", + }, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with an undefined cause + `throw new Error("something went wrong", { cause: undefined })`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + exception: { + preview: { + cause: { + type: "undefined", + }, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with a number cause + `throw new Error("something went wrong", { cause: 0 })`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + exception: { + preview: { + cause: 0, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with a string cause + `throw new Error("something went wrong", { cause: "ooops" })`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + exception: { + preview: { + cause: "ooops", + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], +]); diff --git a/devtools/shared/commands/resource/tests/browser_resources_getAllResources.js b/devtools/shared/commands/resource/tests/browser_resources_getAllResources.js new file mode 100644 index 0000000000..10bc8390d9 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_getAllResources.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test getAllResources function of the ResourceCommand. + +const TEST_URI = "data:text/html;charset=utf-8,getAllResources test"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check the resources gotten from getAllResources at initial"); + is( + resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE) + .length, + 0, + "There is no resources at initial" + ); + + info( + "Start to watch the available resources in order to compare with resources gotten from getAllResources" + ); + const availableResources = []; + const onAvailable = resources => availableResources.push(...resources); + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + + info("Check the resources after some resources are available"); + const messages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, messages); + + try { + await waitFor(() => availableResources.length === messages.length); + } catch (e) { + ok( + false, + `Didn't receive the expected number of resources. Got ${ + availableResources.length + }, expected ${messages.length} - ${availableResources + .map(r => r.message.arguments[0]) + .join(" - ")}` + ); + } + + assertResources( + resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE), + availableResources + ); + assertResources( + resourceCommand.getAllResources(resourceCommand.TYPES.STYLESHEET), + [] + ); + + info("Check the resources after reloading"); + await BrowserTestUtils.reloadTab(tab); + assertResources( + resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE), + [] + ); + + info("Append some resources again to test unwatching"); + const newMessages = ["d", "e", "f"]; + await logMessages(tab.linkedBrowser, messages); + try { + await waitFor( + () => + resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE) + .length === newMessages.length + ); + } catch (e) { + const resources = resourceCommand.getAllResources( + resourceCommand.TYPES.CONSOLE_MESSAGE + ); + ok( + false, + `Didn't receive the expected number of resources. Got ${ + resources.length + }, expected ${messages.length} - ${resources + .map(r => r.message.arguments.join(" | ")) + .join(" - ")}` + ); + } + + info("Check the resources after unwatching"); + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); + assertResources( + resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE), + [] + ); + + targetCommand.destroy(); + await client.close(); +}); + +function assertResources(resources, expectedResources) { + is( + resources.length, + expectedResources.length, + "Number of the resources is correct" + ); + + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + const expectedResource = expectedResources[i]; + Assert.strictEqual( + resource, + expectedResource, + `The ${i}th resource is correct` + ); + } +} + +function logMessages(browser, messages) { + return SpecialPowers.spawn(browser, [messages], innerMessages => { + for (const message of innerMessages) { + content.console.log(message); + } + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js b/devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js new file mode 100644 index 0000000000..8a1d809f04 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test watch/unwatchResources throw when provided with invalid types. + +const TEST_URI = "data:text/html;charset=utf-8,invalid api usage test"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const onAvailable = function () {}; + + await Assert.rejects( + resourceCommand.watchResources([null], { onAvailable }), + /ResourceCommand\.watchResources invoked with an unknown type/, + "watchResources should throw for null type" + ); + + await Assert.rejects( + resourceCommand.watchResources([undefined], { onAvailable }), + /ResourceCommand\.watchResources invoked with an unknown type/, + "watchResources should throw for undefined type" + ); + + await Assert.rejects( + resourceCommand.watchResources(["NOT_A_RESOURCE"], { onAvailable }), + /ResourceCommand\.watchResources invoked with an unknown type/, + "watchResources should throw for unknown type" + ); + + await Assert.rejects( + resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE, "NOT_A_RESOURCE"], + { onAvailable } + ), + /ResourceCommand\.watchResources invoked with an unknown type/, + "watchResources should throw for unknown type mixed with a correct type" + ); + + await Assert.throws( + () => resourceCommand.unwatchResources([null], { onAvailable }), + /ResourceCommand\.unwatchResources invoked with an unknown type/, + "unwatchResources should throw for null type" + ); + + await Assert.throws( + () => resourceCommand.unwatchResources([undefined], { onAvailable }), + /ResourceCommand\.unwatchResources invoked with an unknown type/, + "unwatchResources should throw for undefined type" + ); + + await Assert.throws( + () => resourceCommand.unwatchResources(["NOT_A_RESOURCE"], { onAvailable }), + /ResourceCommand\.unwatchResources invoked with an unknown type/, + "unwatchResources should throw for unknown type" + ); + + await Assert.throws( + () => + resourceCommand.unwatchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE, "NOT_A_RESOURCE"], + { onAvailable } + ), + /ResourceCommand\.unwatchResources invoked with an unknown type/, + "unwatchResources should throw for unknown type mixed with a correct type" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js b/devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js new file mode 100644 index 0000000000..1e2d894be3 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Verify that LAST_PRIVATE_CONTEXT_EXIT fires when closing the last opened private window + +"use strict"; + +const NON_PRIVATE_TEST_URI = + "data:text/html;charset=utf8,<!DOCTYPE html>Not private"; +const PRIVATE_TEST_URI = `data:text/html;charset=utf8,<!DOCTYPE html>Test in private windows`; + +add_task(async function () { + await pushPref("devtools.browsertoolbox.scope", "everything"); + const { commands } = await initMultiProcessResourceCommand(); + const { resourceCommand } = commands; + + const availableResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.LAST_PRIVATE_CONTEXT_EXIT], + { + onAvailable(resources) { + availableResources.push(resources); + }, + } + ); + is( + availableResources.length, + 0, + "We do not get any LAST_PRIVATE_CONTEXT_EXIT after initialization" + ); + + await addTab(NON_PRIVATE_TEST_URI); + + info("Open a new private window and select the new tab opened in it"); + const privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + ok(PrivateBrowsingUtils.isWindowPrivate(privateWindow), "window is private"); + const privateBrowser = privateWindow.gBrowser; + privateBrowser.selectedTab = BrowserTestUtils.addTab( + privateBrowser, + PRIVATE_TEST_URI + ); + + info("private tab opened"); + ok( + PrivateBrowsingUtils.isBrowserPrivate(privateBrowser.selectedBrowser), + "tab window is private" + ); + + info("Open a second tab in the private window"); + await addTab(PRIVATE_TEST_URI, { window: privateWindow }); + + // Let a chance to an unexpected async event to be fired + await wait(1000); + + is( + availableResources.length, + 0, + "We do not get any LAST_PRIVATE_CONTEXT_EXIT when opening a private window" + ); + + info("Open a second private browsing window"); + const secondPrivateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Close the second private window"); + secondPrivateWindow.BrowserTryToCloseWindow(); + + // Let a chance to an unexpected async event to be fired + await wait(1000); + + is( + availableResources.length, + 0, + "We do not get any LAST_PRIVATE_CONTEXT_EXIT when closing the second private window only" + ); + + info( + "close the private window and check if LAST_PRIVATE_CONTEXT_EXIT resource is sent" + ); + privateWindow.BrowserTryToCloseWindow(); + + info("Wait for LAST_PRIVATE_CONTEXT_EXIT"); + await waitFor(() => availableResources.length == 1); + is( + availableResources.length, + 1, + "We get one LAST_PRIVATE_CONTEXT_EXIT when closing the last opened private window" + ); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js b/devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js new file mode 100644 index 0000000000..2200fcad9c --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around NETWORK_EVENT_STACKTRACE + +const TEST_URI = `${URL_ROOT_SSL}network_document.html`; + +const REQUEST_STUB = { + code: `await fetch("/request_post_0.html", { method: "POST" });`, + expected: { + stacktraceAvailable: true, + lastFrame: { + filename: + "https://example.com/browser/devtools/shared/commands/resource/tests/network_document.html", + lineNumber: 1, + columnNumber: 40, + functionName: "triggerRequest", + asyncCause: null, + }, + }, +}; + +add_task(async function () { + info("Test network stacktraces events"); + const tab = await addTab(TEST_URI); + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const networkEvents = new Map(); + const stackTraces = new Map(); + + function onResourceAvailable(resources) { + for (const resource of resources) { + if ( + resource.resourceType === resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE + ) { + ok( + !networkEvents.has(resource.resourceId), + "The network event does not exist" + ); + + is( + resource.stacktraceAvailable, + REQUEST_STUB.expected.stacktraceAvailable, + "The stacktrace is available" + ); + is( + JSON.stringify(resource.lastFrame), + JSON.stringify(REQUEST_STUB.expected.lastFrame), + "The last frame of the stacktrace is available" + ); + + stackTraces.set(resource.resourceId, true); + return; + } + + if (resource.resourceType === resourceCommand.TYPES.NETWORK_EVENT) { + ok( + stackTraces.has(resource.stacktraceResourceId), + "The stack trace does exists" + ); + + networkEvents.set(resource.resourceId, true); + } + } + } + + function onResourceUpdated() {} + + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE, + resourceCommand.TYPES.NETWORK_EVENT, + ], + { + onAvailable: onResourceAvailable, + onUpdated: onResourceUpdated, + } + ); + + await triggerNetworkRequests(tab.linkedBrowser, [REQUEST_STUB.code]); + + resourceCommand.unwatchResources( + [ + resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE, + resourceCommand.TYPES.NETWORK_EVENT, + ], + { + onAvailable: onResourceAvailable, + onUpdated: onResourceUpdated, + } + ); + + targetCommand.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events.js b/devtools/shared/commands/resource/tests/browser_resources_network_events.js new file mode 100644 index 0000000000..da355fd023 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_events.js @@ -0,0 +1,318 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around NETWORK_EVENT + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +// We are borrowing tests from the netmonitor frontend +const NETMONITOR_TEST_FOLDER = + "https://example.com/browser/devtools/client/netmonitor/test/"; +const CSP_URL = `${NETMONITOR_TEST_FOLDER}html_csp-test-page.html`; +const JS_CSP_URL = `${NETMONITOR_TEST_FOLDER}js_websocket-worker-test.js`; +const CSS_CSP_URL = `${NETMONITOR_TEST_FOLDER}internal-loaded.css`; + +const CSP_BLOCKED_REASON_CODE = 4000; + +add_task(async function testContentProcessRequests() { + info(`Tests for NETWORK_EVENT resources fired from the content process`); + + const expectedAvailable = [ + { + url: CSP_URL, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: JS_CSP_URL, + method: "GET", + blockedReason: CSP_BLOCKED_REASON_CODE, + isNavigationRequest: false, + chromeContext: false, + }, + { + url: CSS_CSP_URL, + method: "GET", + blockedReason: CSP_BLOCKED_REASON_CODE, + isNavigationRequest: false, + chromeContext: false, + }, + ]; + const expectedUpdated = [ + { + url: CSP_URL, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: JS_CSP_URL, + method: "GET", + blockedReason: CSP_BLOCKED_REASON_CODE, + isNavigationRequest: false, + chromeContext: false, + }, + { + url: CSS_CSP_URL, + method: "GET", + blockedReason: CSP_BLOCKED_REASON_CODE, + isNavigationRequest: false, + chromeContext: false, + }, + ]; + + await assertNetworkResourcesOnPage( + CSP_URL, + expectedAvailable, + expectedUpdated + ); +}); + +add_task(async function testCanceledRequest() { + info(`Tests for NETWORK_EVENT resources with a canceled request`); + + // Do a XHR request that we cancel against a slow loading page + const requestUrl = + "https://example.org/document-builder.sjs?delay=1000&html=foo"; + const html = + "<!DOCTYPE html><script>(" + + function (xhrUrl) { + const xhr = new XMLHttpRequest(); + xhr.open("GET", xhrUrl); + xhr.send(null); + } + + ")(" + + JSON.stringify(requestUrl) + + ")</script>"; + const pageUrl = + "https://example.org/document-builder.sjs?html=" + encodeURIComponent(html); + + const expectedAvailable = [ + { + url: pageUrl, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: requestUrl, + method: "GET", + isNavigationRequest: false, + blockedReason: "NS_BINDING_ABORTED", + chromeContext: false, + }, + ]; + const expectedUpdated = [ + { + url: pageUrl, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: requestUrl, + method: "GET", + isNavigationRequest: false, + blockedReason: "NS_BINDING_ABORTED", + chromeContext: false, + }, + ]; + + // Register a one-off listener to cancel the XHR request + // Using XMLHttpRequest's abort() method from the content process + // isn't reliable and would introduce many race condition in the test. + // Canceling the request via nsIRequest.cancel privileged method, + // from the parent process is much more reliable. + const observer = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + observe(subject, topic, data) { + subject = subject.QueryInterface(Ci.nsIHttpChannel); + if (subject.URI.spec == requestUrl) { + subject.cancel(Cr.NS_BINDING_ABORTED); + Services.obs.removeObserver(observer, "http-on-modify-request"); + } + }, + }; + Services.obs.addObserver(observer, "http-on-modify-request"); + + await assertNetworkResourcesOnPage( + pageUrl, + expectedAvailable, + expectedUpdated + ); +}); + +add_task(async function testIframeRequest() { + info(`Tests for NETWORK_EVENT resources with an iframe`); + + // Do a XHR request that we cancel against a slow loading page + const iframeRequestUrl = + "https://example.org/document-builder.sjs?html=iframe-request"; + const iframeHtml = `iframe<script>fetch("${iframeRequestUrl}")</script>`; + const iframeUrl = + "https://example.org/document-builder.sjs?html=" + + encodeURIComponent(iframeHtml); + const html = `top-document<iframe src="${iframeUrl}"></iframe>`; + const pageUrl = + "https://example.org/document-builder.sjs?html=" + encodeURIComponent(html); + + const expectedAvailable = [ + { + url: pageUrl, + method: "GET", + chromeContext: false, + isNavigationRequest: true, + // The top level navigation request relates to the previous top level target. + // Unfortunately, it is hard to test because it is racy. + // The target front might be destroyed and `targetFront.url` will be null. + // Or not just yet and be equal to "about:blank". + }, + { + url: iframeUrl, + method: "GET", + isNavigationRequest: false, + targetFrontUrl: pageUrl, + chromeContext: false, + }, + { + url: iframeRequestUrl, + method: "GET", + isNavigationRequest: false, + targetFrontUrl: iframeUrl, + chromeContext: false, + }, + ]; + const expectedUpdated = [ + { + url: pageUrl, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: iframeUrl, + method: "GET", + isNavigationRequest: false, + chromeContext: false, + }, + { + url: iframeRequestUrl, + method: "GET", + isNavigationRequest: false, + chromeContext: false, + }, + ]; + + await assertNetworkResourcesOnPage( + pageUrl, + expectedAvailable, + expectedUpdated + ); +}); + +async function assertNetworkResourcesOnPage( + url, + expectedAvailable, + expectedUpdated +) { + // First open a blank document to avoid spawning any request + const tab = await addTab("about:blank"); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + const { resourceCommand } = commands; + + const onAvailable = resources => { + for (const resource of resources) { + // Immediately assert the resource, as the same resource object + // will be notified to onUpdated and so if we assert it later + // we will not highlight attributes that aren't set yet from onAvailable. + const idx = expectedAvailable.findIndex(e => e.url === resource.url); + Assert.notEqual( + idx, + -1, + "Found a matching available notification for: " + resource.url + ); + // Remove the match from the list in case there is many requests with the same url + const [expected] = expectedAvailable.splice(idx, 1); + + assertResources(resource, expected); + } + }; + const onUpdated = updates => { + for (const { resource } of updates) { + const idx = expectedUpdated.findIndex(e => e.url === resource.url); + Assert.notEqual( + idx, + -1, + "Found a matching updated notification for: " + resource.url + ); + // Remove the match from the list in case there is many requests with the same url + const [expected] = expectedUpdated.splice(idx, 1); + + assertResources(resource, expected); + } + }; + + // Start observing for network events before loading the test page + await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable, + onUpdated, + }); + + // Load the test page that fires network requests + const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, url); + await onLoaded; + + // Make sure we processed all the expected request updates + await waitFor( + () => !expectedAvailable.length, + "Wait for all expected available notifications" + ); + await waitFor( + () => !expectedUpdated.length, + "Wait for all expected updated notifications" + ); + + resourceCommand.unwatchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable, + onUpdated, + }); + + await commands.destroy(); + + BrowserTestUtils.removeTab(tab); +} + +function assertResources(actual, expected) { + is( + actual.resourceType, + ResourceCommand.TYPES.NETWORK_EVENT, + "The resource type is correct" + ); + is( + typeof actual.innerWindowId, + "number", + "All requests have an innerWindowId attribute" + ); + ok( + actual.targetFront.isTargetFront, + "All requests have a targetFront attribute" + ); + + for (const name in expected) { + if (name == "targetFrontUrl") { + is( + actual.targetFront.url, + expected[name], + "The request matches the right target front" + ); + } else { + is(actual[name], expected[name], `The '${name}' attribute is correct`); + } + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js b/devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js new file mode 100644 index 0000000000..6708ef19e1 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js @@ -0,0 +1,236 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API internal cache / ignoreExistingResources around NETWORK_EVENT + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const EXAMPLE_DOMAIN = "https://example.com/"; +const TEST_URI = `${URL_ROOT_SSL}network_document.html`; + +add_task(async function () { + info("Test basic NETWORK_EVENT resources against ResourceCommand cache"); + await testNetworkEventResourcesWithExistingResources(); + await testNetworkEventResourcesWithoutExistingResources(); +}); + +async function testNetworkEventResourcesWithExistingResources() { + info(`Tests for network event resources with the existing resources`); + await testNetworkEventResourcesWithCachedRequest({ + ignoreExistingResources: false, + // 1 available event fired, for the existing resource in the cache. + // 1 available event fired, when live request is created. + expectedResourcesOnAvailable: { + [`${EXAMPLE_DOMAIN}cached_post.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "POST", + isNavigationRequest: false, + }, + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "GET", + isNavigationRequest: false, + }, + }, + // 1 update events fired, when live request is updated. + expectedResourcesOnUpdated: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "GET", + }, + }, + }); +} + +async function testNetworkEventResourcesWithoutExistingResources() { + info(`Tests for network event resources without the existing resources`); + await testNetworkEventResourcesWithCachedRequest({ + ignoreExistingResources: true, + // 1 available event fired, when live request is created. + expectedResourcesOnAvailable: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "GET", + isNavigationRequest: false, + }, + }, + // 1 update events fired, when live request is updated. + expectedResourcesOnUpdated: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "GET", + }, + }, + }); +} + +/** + * This test helper is slightly complex as we workaround the fact + * that the server is not able to record network request done in the past. + * Because of that we have to start observer requests via ResourceCommand.watchResources + * before doing a request, and, before doing the actual call to watchResources + * we want to assert the behavior of. + */ +async function testNetworkEventResourcesWithCachedRequest(options) { + const tab = await addTab(TEST_URI); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const { resourceCommand } = commands; + + info( + `Trigger some network requests *before* calling ResourceCommand.watchResources + in order to assert the behavior of already existing network events.` + ); + + // Register a first empty listener in order to ensure populating ResourceCommand + // internal cache of NETWORK_EVENT's. We can't retrieved past network requests + // when calling server's `watchResources`. + let resolveCachedRequestAvailable; + const onCachedRequestAvailable = new Promise( + r => (resolveCachedRequestAvailable = r) + ); + const onAvailableToPopulateInternalCache = () => {}; + const onUpdatedToPopulateInternalCache = resolveCachedRequestAvailable; + await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], { + ignoreExistingResources: true, + onAvailable: onAvailableToPopulateInternalCache, + onUpdated: onUpdatedToPopulateInternalCache, + }); + + // We can only trigger the requests once `watchResources` settles, + // otherwise we might miss some events and they won't be present in the cache + const cachedRequest = `await fetch("/cached_post.html", { method: "POST" });`; + await triggerNetworkRequests(tab.linkedBrowser, [cachedRequest]); + + // We have to ensure that ResourceCommand processed the Resource for this first + // cached request before calling watchResource a second time and report it. + // Wait for the updated notification to avoid receiving it during the next call + // to watchResources. + await onCachedRequestAvailable; + + const actualResourcesOnAvailable = {}; + const actualResourcesOnUpdated = {}; + + const { + expectedResourcesOnAvailable, + expectedResourcesOnUpdated, + + ignoreExistingResources, + } = options; + + const onAvailable = resources => { + for (const resource of resources) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network event resource" + ); + actualResourcesOnAvailable[resource.url] = resource; + } + }; + + const onUpdated = updates => { + for (const { resource } of updates) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network update event resource" + ); + actualResourcesOnUpdated[resource.url] = resource; + } + }; + + await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable, + onUpdated, + ignoreExistingResources, + }); + + info( + `Trigger the rest of the requests *after* calling ResourceCommand.watchResources + in order to assert the behavior of live network events.` + ); + const liveRequest = `await fetch("/live_get.html", { method: "GET" });`; + await triggerNetworkRequests(tab.linkedBrowser, [liveRequest]); + + info("Check the resources on available"); + + await waitUntil( + () => + Object.keys(actualResourcesOnAvailable).length == + Object.keys(expectedResourcesOnAvailable).length + ); + + is( + Object.keys(actualResourcesOnAvailable).length, + Object.keys(expectedResourcesOnAvailable).length, + "Got the expected number of network events fired onAvailable" + ); + + // assert the resources emitted when the network event is created + for (const key in expectedResourcesOnAvailable) { + const expected = expectedResourcesOnAvailable[key]; + const actual = actualResourcesOnAvailable[key]; + assertResources(actual, expected); + } + + info("Check the resources on updated"); + + await waitUntil( + () => + Object.keys(actualResourcesOnUpdated).length == + Object.keys(expectedResourcesOnUpdated).length + ); + + is( + Object.keys(actualResourcesOnUpdated).length, + Object.keys(expectedResourcesOnUpdated).length, + "Got the expected number of network events fired onUpdated" + ); + + // assert the resources emitted when the network event is updated + for (const key in expectedResourcesOnUpdated) { + const expected = expectedResourcesOnUpdated[key]; + const actual = actualResourcesOnUpdated[key]; + assertResources(actual, expected); + // assert that the resourceId for the the available and updated events match + is( + actual.resourceId, + actualResourcesOnAvailable[key].resourceId, + `Available and update resource ids for ${key} are the same` + ); + } + + resourceCommand.unwatchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable, + onUpdated, + ignoreExistingResources, + }); + + resourceCommand.unwatchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable: onAvailableToPopulateInternalCache, + }); + + await commands.destroy(); + + BrowserTestUtils.removeTab(tab); +} + +function assertResources(actual, expected) { + is( + actual.resourceType, + expected.resourceType, + "The resource type is correct" + ); + is(actual.method, expected.method, "The method is correct"); + if ("isNavigationRequest" in expected) { + is( + actual.isNavigationRequest, + expected.isNavigationRequest, + "The isNavigationRequest attribute is correct" + ); + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js b/devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js new file mode 100644 index 0000000000..44028318a2 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around NETWORK_EVENT when navigating + +const TEST_URI = `${URL_ROOT_SSL}network_document_navigation.html`; +const JS_URI = TEST_URI.replace( + "network_document_navigation.html", + "network_navigation.js" +); + +add_task(async () => { + const tab = await addTab(TEST_URI); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + const { resourceCommand } = commands; + + const receivedResources = []; + const onAvailable = resources => { + for (const resource of resources) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network event resource" + ); + receivedResources.push(resource); + } + }; + const onUpdated = updates => { + for (const { resource } of updates) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network update event resource" + ); + } + }; + + await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], { + ignoreExistingResources: true, + onAvailable, + onUpdated, + }); + + await reloadBrowser(); + + await waitFor(() => receivedResources.length == 2); + + const navigationRequest = receivedResources[0]; + is( + navigationRequest.url, + TEST_URI, + "The first resource is for the navigation request" + ); + + const jsRequest = receivedResources[1]; + is(jsRequest.url, JS_URI, "The second resource is for the javascript file"); + + async function getResponseContent(networkEvent) { + const packet = { + to: networkEvent.actor, + type: "getResponseContent", + }; + const response = await commands.client.request(packet); + return response.content.text; + } + + const HTML_CONTENT = await (await fetch(TEST_URI)).text(); + const JS_CONTENT = await (await fetch(JS_URI)).text(); + + const htmlContent = await getResponseContent(navigationRequest); + is(htmlContent, HTML_CONTENT); + const jsContent = await getResponseContent(jsRequest); + is(jsContent, JS_CONTENT); + + await reloadBrowser(); + + await waitFor(() => receivedResources.length == 4); + + try { + await getResponseContent(navigationRequest); + ok(false, "Shouldn't work"); + } catch (e) { + is( + e.error, + "noSuchActor", + "Without persist, we can't fetch previous document network data" + ); + } + + try { + await getResponseContent(jsRequest); + ok(false, "Shouldn't work"); + } catch (e) { + is( + e.error, + "noSuchActor", + "Without persist, we can't fetch previous document network data" + ); + } + + const navigationRequest2 = receivedResources[2]; + const jsRequest2 = receivedResources[3]; + info("But we can fetch data for the last/new document"); + const htmlContent2 = await getResponseContent(navigationRequest2); + is(htmlContent2, HTML_CONTENT); + const jsContent2 = await getResponseContent(jsRequest2); + is(jsContent2, JS_CONTENT); + + info("Enable persist"); + const networkParentFront = + await commands.watcherFront.getNetworkParentActor(); + await networkParentFront.setPersist(true); + + await reloadBrowser(); + + await waitFor(() => receivedResources.length == 6); + + info("With persist, we can fetch previous document network data"); + const htmlContent3 = await getResponseContent(navigationRequest2); + is(htmlContent3, HTML_CONTENT); + const jsContent3 = await getResponseContent(jsRequest2); + is(jsContent3, JS_CONTENT); + + await resourceCommand.unwatchResources( + [resourceCommand.TYPES.NETWORK_EVENT], + { + onAvailable, + onUpdated, + } + ); + + await commands.destroy(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js b/devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js new file mode 100644 index 0000000000..c5b3e436db --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js @@ -0,0 +1,249 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * !! AFTER MOVING OR RENAMING THIS METHOD, UPDATE `EXPECTED` CONSTANTS BELOW !! + */ +const createParentProcessRequests = async () => { + info("Do some requests from the parent process"); + // The line:column for `fetch` should be EXPECTED_REQUEST_LINE_1/COL_1 + await fetch(FETCH_URI); + + const img = new Image(); + const onLoad = new Promise(r => img.addEventListener("load", r)); + // The line:column for `img` below should be EXPECTED_REQUEST_LINE_2/COL_2 + img.src = IMAGE_URI; + await onLoad; +}; + +const EXPECTED_METHOD_NAME = "createParentProcessRequests"; +const EXPECTED_REQUEST_LINE_1 = 12; +const EXPECTED_REQUEST_COL_1 = 9; +const EXPECTED_REQUEST_LINE_2 = 17; +const EXPECTED_REQUEST_COL_2 = 3; + +// Test the ResourceCommand API around NETWORK_EVENT for the parent process + +const FETCH_URI = "https://example.com/document-builder.sjs?html=foo"; +// The img.src request gets cached regardless of `devtools.cache.disabled`. +// Add a random parameter to the request to bypass the cache. +const uuid = `${Date.now()}-${Math.random()}`; +const IMAGE_URI = URL_ROOT_SSL + "test_image.png?" + uuid; + +add_task(async function testParentProcessRequests() { + // The test expects the main process commands instance to receive resources + // for content process requests. + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const commands = await CommandsFactory.forMainProcess(); + await commands.targetCommand.startListening(); + const { resourceCommand } = commands; + + const receivedNetworkEvents = []; + const receivedStacktraces = []; + const onAvailable = resources => { + for (const resource of resources) { + if (resource.resourceType == resourceCommand.TYPES.NETWORK_EVENT) { + receivedNetworkEvents.push(resource); + } else if ( + resource.resourceType == resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE + ) { + receivedStacktraces.push(resource); + } + } + }; + const onUpdated = updates => { + for (const { resource } of updates) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network update event resource" + ); + } + }; + + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.NETWORK_EVENT, + resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE, + ], + { + ignoreExistingResources: true, + onAvailable, + onUpdated, + } + ); + + await createParentProcessRequests(); + + const img2 = new Image(); + img2.src = IMAGE_URI; + + info("Wait for the network events"); + await waitFor(() => receivedNetworkEvents.length == 3); + info("Wait for the network events stack traces"); + // Note that we aren't getting any stacktrace for the second cached request + await waitFor(() => receivedStacktraces.length == 2); + + info("Assert the fetch request"); + const fetchRequest = receivedNetworkEvents[0]; + is( + fetchRequest.url, + FETCH_URI, + "The first resource is for the fetch request" + ); + ok(fetchRequest.chromeContext, "The fetch request is privileged"); + + const fetchStacktrace = receivedStacktraces[0].lastFrame; + is(receivedStacktraces[0].resourceId, fetchRequest.stacktraceResourceId); + is(fetchStacktrace.filename, gTestPath); + is(fetchStacktrace.lineNumber, EXPECTED_REQUEST_LINE_1); + is(fetchStacktrace.columnNumber, EXPECTED_REQUEST_COL_1); + is(fetchStacktrace.functionName, EXPECTED_METHOD_NAME); + is(fetchStacktrace.asyncCause, null); + + async function getResponseContent(networkEvent) { + const packet = { + to: networkEvent.actor, + type: "getResponseContent", + }; + const response = await commands.client.request(packet); + return response.content.text; + } + + const fetchContent = await getResponseContent(fetchRequest); + is(fetchContent, "foo"); + + info("Assert the first image request"); + const firstImageRequest = receivedNetworkEvents[1]; + is( + firstImageRequest.url, + IMAGE_URI, + "The second resource is for the first image request" + ); + ok(!firstImageRequest.fromCache, "The first image request isn't cached"); + ok(firstImageRequest.chromeContext, "The first image request is privileged"); + + const firstImageStacktrace = receivedStacktraces[1].lastFrame; + is(receivedStacktraces[1].resourceId, firstImageRequest.stacktraceResourceId); + is(firstImageStacktrace.filename, gTestPath); + is(firstImageStacktrace.lineNumber, EXPECTED_REQUEST_LINE_2); + is(firstImageStacktrace.columnNumber, EXPECTED_REQUEST_COL_2); + is(firstImageStacktrace.functionName, EXPECTED_METHOD_NAME); + is(firstImageStacktrace.asyncCause, null); + + info("Assert the second image request"); + const secondImageRequest = receivedNetworkEvents[2]; + is( + secondImageRequest.url, + IMAGE_URI, + "The third resource is for the second image request" + ); + ok(secondImageRequest.fromCache, "The second image request is cached"); + ok( + secondImageRequest.chromeContext, + "The second image request is privileged" + ); + + info( + "Open a content page to ensure we also receive request from content processes" + ); + const pageUrl = "https://example.org/document-builder.sjs?html=foo"; + const requestUrl = "https://example.org/document-builder.sjs?html=bar"; + const tab = await addTab(pageUrl); + + await waitFor(() => receivedNetworkEvents.length == 4); + const tabRequest = receivedNetworkEvents[3]; + is(tabRequest.url, pageUrl, "The 4th resource is for the tab request"); + ok(!tabRequest.chromeContext, "The 4th request is content"); + + info( + "Also spawn a privileged request from the content process, not bound to any WindowGlobal" + ); + await SpecialPowers.spawn( + tab.linkedBrowser, + [requestUrl], + async function (uri) { + const { NetUtil } = ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" + ); + const channel = NetUtil.newChannel({ + uri, + loadUsingSystemPrincipal: true, + }); + channel.open(); + } + ); + await removeTab(tab); + + await waitFor(() => receivedNetworkEvents.length == 5); + const privilegedContentRequest = receivedNetworkEvents[4]; + is( + privilegedContentRequest.url, + requestUrl, + "The 5th resource is for the privileged content process request" + ); + ok(privilegedContentRequest.chromeContext, "The 5th request is privileged"); + + info("Now focus only on parent process resources"); + await pushPref("devtools.browsertoolbox.scope", "parent-process"); + + info( + "Retrigger the two last requests. The tab document request and a privileged request. Both happening in the tab's content process." + ); + const secondTab = await addTab(pageUrl); + await SpecialPowers.spawn( + secondTab.linkedBrowser, + [requestUrl], + async function (uri) { + const { NetUtil } = ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" + ); + const channel = NetUtil.newChannel({ + uri, + loadUsingSystemPrincipal: true, + }); + channel.open(); + } + ); + + await waitFor(() => receivedNetworkEvents.length == 6); + + // nsIHttpChannel doesn't expose any attribute allowing to identify + // privileged requests done in content processes. + // Thus, preventing us from filtering them out correctly. + // Ideally, we would need some new attribute to know from which (content) process + // any channel originates from. + info( + "For now, we are still notified about the privileged content process request" + ); + const secondPrivilegedContentRequest = receivedNetworkEvents[5]; + is( + secondPrivilegedContentRequest.url, + requestUrl, + "The 6th resource is for the second privileged content process request" + ); + ok(privilegedContentRequest.chromeContext, "The 6th request is privileged"); + + // Let some time to receive the tab request if that's not correctly filtered out + await wait(1000); + is( + receivedNetworkEvents.length, + 6, + "But we don't receive the request for the tab request" + ); + + await removeTab(secondTab); + + await resourceCommand.unwatchResources( + [resourceCommand.TYPES.NETWORK_EVENT], + { + onAvailable, + onUpdated, + } + ); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_platform_messages.js b/devtools/shared/commands/resource/tests/browser_resources_platform_messages.js new file mode 100644 index 0000000000..4e74a97e38 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_platform_messages.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around PLATFORM_MESSAGE +// Reproduces assertions from: devtools/shared/webconsole/test/chrome/test_nsiconsolemessage.html + +add_task(async function () { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + await testPlatformMessagesResources(); + await testPlatformMessagesResourcesWithIgnoreExistingResources(); +}); + +async function testPlatformMessagesResources() { + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + const cachedMessages = [ + "This is a cached message", + "This is another cached message", + ]; + const liveMessages = [ + "This is a live message", + "This is another live message", + ]; + const expectedMessages = [...cachedMessages, ...liveMessages]; + const receivedMessages = []; + + info( + "Log some messages *before* calling ResourceCommand.watchResources in order to assert the behavior of already existing messages." + ); + Services.console.logStringMessage(expectedMessages[0]); + Services.console.logStringMessage(expectedMessages[1]); + + let done; + const onAllMessagesReceived = new Promise(resolve => (done = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + if (!expectedMessages.includes(resource.message)) { + continue; + } + + is( + resource.targetFront, + targetCommand.targetFront, + "The targetFront property is the expected one" + ); + + receivedMessages.push(resource.message); + is( + resource.message, + expectedMessages[receivedMessages.length - 1], + `Received the expected «${resource.message}» message, in the expected order` + ); + + // timeStamp are the result of a number in microsecond divided by 1000. + // so we can't expect a precise number of decimals, or even if there would + // be decimals at all. + ok( + resource.timeStamp.toString().match(/^\d+(\.\d{1,3})?$/), + `The resource has a timeStamp property ${resource.timeStamp}` + ); + + const isCachedMessage = receivedMessages.length <= cachedMessages.length; + is( + resource.isAlreadyExistingResource, + isCachedMessage, + "isAlreadyExistingResource has the expected value" + ); + + if (receivedMessages.length == expectedMessages.length) { + done(); + } + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.PLATFORM_MESSAGE], + { + onAvailable, + } + ); + + info( + "Now log messages *after* the call to ResourceCommand.watchResources and after having received all existing messages" + ); + Services.console.logStringMessage(expectedMessages[2]); + Services.console.logStringMessage(expectedMessages[3]); + + info("Waiting for all expected messages to be received"); + await onAllMessagesReceived; + ok(true, "All the expected messages were received"); + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} + +async function testPlatformMessagesResourcesWithIgnoreExistingResources() { + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + info( + "Check whether onAvailable will not be called with existing platform messages" + ); + const expectedMessages = ["This is 1st message", "This is 2nd message"]; + Services.console.logStringMessage(expectedMessages[0]); + Services.console.logStringMessage(expectedMessages[1]); + + const availableResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.PLATFORM_MESSAGE], + { + onAvailable: resources => { + for (const resource of resources) { + if (!expectedMessages.includes(resource.message)) { + continue; + } + + availableResources.push(resource); + } + }, + ignoreExistingResources: true, + } + ); + is( + availableResources.length, + 0, + "onAvailable wasn't called for existing platform messages" + ); + + info( + "Check whether onAvailable will be called with the future platform messages" + ); + Services.console.logStringMessage(expectedMessages[0]); + Services.console.logStringMessage(expectedMessages[1]); + + await waitUntil(() => availableResources.length === expectedMessages.length); + for (let i = 0; i < expectedMessages.length; i++) { + const resource = availableResources[i]; + const { message } = resource; + const expected = expectedMessages[i]; + is(message, expected, `Message[${i}] is correct`); + is( + resource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false since we ignore existing resources" + ); + } + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_reflows.js b/devtools/shared/commands/resource/tests/browser_resources_reflows.js new file mode 100644 index 0000000000..70242c826a --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_reflows.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API for reflows + +const { + TYPES, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +add_task(async function () { + const tab = await addTab( + "https://example.com/document-builder.sjs?html=<h1>Test reflow resources</h1>" + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const resources = []; + const onAvailable = _resources => { + resources.push(..._resources); + }; + await resourceCommand.watchResources([TYPES.REFLOW], { + onAvailable, + }); + + is(resources.length, 0, "No reflow resource were sent initially"); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + const el = content.document.createElement("div"); + el.textContent = "1"; + content.document.body.appendChild(el); + }); + + await waitFor(() => resources.length === 1); + checkReflowResource(resources[0]); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + const el = content.document.querySelector("div"); + el.style.display = "inline-grid"; + }); + + await waitFor(() => resources.length === 2); + ok( + true, + "A reflow resource is sent when the display property of an element is modified" + ); + checkReflowResource(resources.at(-1)); + + info("Check that adding an iframe does emit a reflow"); + const iframeBC = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + async () => { + const el = content.document.createElement("iframe"); + const onIframeLoaded = new Promise(resolve => + el.addEventListener("load", resolve, { once: true }) + ); + content.document.body.appendChild(el); + el.src = + "https://example.org/document-builder.sjs?html=<h2>remote iframe</h2>"; + await onIframeLoaded; + return el.browsingContext; + } + ); + + await waitFor(() => resources.length === 3); + ok(true, "A reflow resource was received when adding a remote iframe"); + checkReflowResource(resources.at(-1)); + + info("Check that we receive reflow resources for the remote iframe"); + await SpecialPowers.spawn(iframeBC, [], () => { + const el = content.document.createElement("section"); + el.textContent = "remote org iframe"; + el.style.display = "grid"; + content.document.body.appendChild(el); + }); + + await waitFor(() => resources.length === 4); + if (isFissionEnabled()) { + ok( + resources.at(-1).targetFront.url.includes("example.org"), + "The reflow resource is linked to the remote target" + ); + } + checkReflowResource(resources.at(-1)); + + targetCommand.destroy(); + await client.close(); +}); + +function checkReflowResource(resource) { + is( + resource.resourceType, + TYPES.REFLOW, + "The resource has the expected resourceType" + ); + + ok(Array.isArray(resource.reflows), "the `reflows` property is an array"); + for (const reflow of resource.reflows) { + is( + Number.isFinite(reflow.start), + true, + "reflow start property is a number" + ); + is(Number.isFinite(reflow.end), true, "reflow end property is a number"); + Assert.greaterOrEqual( + reflow.end, + reflow.start, + "end is greater than start" + ); + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_root_node.js b/devtools/shared/commands/resource/tests/browser_resources_root_node.js new file mode 100644 index 0000000000..67ef5efd90 --- /dev/null +++ b/devtools/shared/commands/resource/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 ResourceCommand API around ROOT_NODE + +/** + * The original test still asserts some scenarios using several watchRootNode + * call sites, which is not something we intend to support at the moment in the + * resource command. + * + * Otherwise this test checks the basic behavior of the resource when reloading + * an empty page. + */ +add_task(async function () { + // Open a test tab + const tab = await addTab("data:text/html,Root Node tests"); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const browser = gBrowser.selectedBrowser; + + info("Call watchResources([ROOT_NODE], ...)"); + let onAvailableCounter = 0; + const onAvailable = resources => (onAvailableCounter += resources.length); + await resourceCommand.watchResources([resourceCommand.TYPES.ROOT_NODE], { + onAvailable, + }); + + info("Wait until onAvailable has been called"); + await waitUntil(() => onAvailableCounter === 1); + is(onAvailableCounter, 1, "onAvailable has been called 1 time"); + + info("Reload the selected browser"); + browser.reload(); + + info( + "Wait until the watchResources([ROOT_NODE], ...) callback has been called" + ); + await waitUntil(() => onAvailableCounter === 2); + + is(onAvailableCounter, 2, "onAvailable has been called 2 times"); + + info("Call unwatchResources([ROOT_NODE], ...) for the onAvailable callback"); + resourceCommand.unwatchResources([resourceCommand.TYPES.ROOT_NODE], { + onAvailable, + }); + + info("Reload the selected browser"); + const reloaded = BrowserTestUtils.browserLoaded(browser); + browser.reload(); + await reloaded; + + is( + onAvailableCounter, + 2, + "onAvailable was not called after calling unwatchResources" + ); + + // Cleanup + targetCommand.destroy(); + await client.close(); +}); + +/** + * Test that the watchRootNode API provides the expected node fronts. + */ +add_task(async function testRootNodeFrontIsCorrect() { + const tab = await addTab("data:text/html,<div id=div1>"); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + const browser = gBrowser.selectedBrowser; + + info("Call watchResources([ROOT_NODE], ...)"); + + let rootNodeResolve; + let rootNodePromise = new Promise(r => (rootNodeResolve = r)); + const onAvailable = ([rootNodeFront]) => rootNodeResolve(rootNodeFront); + await resourceCommand.watchResources([resourceCommand.TYPES.ROOT_NODE], { + onAvailable, + }); + + info("Wait until onAvailable has been called"); + const root1 = await rootNodePromise; + ok(!!root1, "onAvailable has been called with a valid argument"); + is( + root1.resourceType, + resourceCommand.TYPES.ROOT_NODE, + "The resource has the expected type" + ); + + info("Check we can query an expected node under the retrieved root"); + const div1 = await root1.walkerFront.querySelector(root1, "div"); + is(div1.getAttribute("id"), "div1", "Correct root node retrieved"); + + info("Reload the selected browser"); + rootNodePromise = new Promise(r => (rootNodeResolve = r)); + browser.reload(); + + const root2 = await rootNodePromise; + Assert.notStrictEqual( + 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.startLoadingURIString( + browser, + `data:text/html,<div id=div3>` + ); + const root3 = await rootNodePromise; + info("Check we can query an expected node under the retrieved root"); + const div3 = await root3.walkerFront.querySelector(root3, "div"); + is(div3.getAttribute("id"), "div3", "Correct root node retrieved"); + + // Cleanup + resourceCommand.unwatchResources([resourceCommand.TYPES.ROOT_NODE], { + onAvailable, + }); + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_scope_flag.js b/devtools/shared/commands/resource/tests/browser_resources_scope_flag.js new file mode 100644 index 0000000000..8537daf161 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_scope_flag.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the ResourceCommand clears its pending events for resources emitted from +// target destroyed when devtools.browsertoolbox.scope is updated. + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`); + +add_task(async function () { + // Do not run this test when both fission and EFT is disabled as it changes + // the number of targets + if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) { + ok(true, "Don't go further is both Fission and EFT are disabled"); + return; + } + + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + // Start with multiprocess debugging enabled + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + const { TYPES } = targetCommand; + + const targets = new Set(); + const onAvailable = async ({ targetFront }) => { + targets.add(targetFront); + }; + const onDestroyed = () => {}; + await targetCommand.watchTargets({ + types: [TYPES.PROCESS, TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + info("Open a tab in a new content process"); + const firstTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + + const newTabInnerWindowId = + firstTab.linkedBrowser.browsingContext.currentWindowGlobal.innerWindowId; + + info("Wait for the tab window global target"); + const windowGlobalTarget = await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.FRAME && + target.innerWindowId == newTabInnerWindowId + ) + ); + + let gotTabResource = false; + const onResourceAvailable = resources => { + for (const resource of resources) { + if (resource.targetFront == windowGlobalTarget) { + gotTabResource = true; + + if (resource.targetFront.isDestroyed()) { + ok( + false, + "we shouldn't get resources for the target that was destroyed when switching mode" + ); + } + } + } + }; + + info("Start listening for resources"); + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: onResourceAvailable, + ignoreExistingResources: true, + } + ); + + // Emit logs every ms to fill up the resourceCommand resource queue (pendingEvents) + const intervalId = await SpecialPowers.spawn( + firstTab.linkedBrowser, + [], + () => { + let counter = 0; + return content.wrappedJSObject.setInterval(() => { + counter++; + content.wrappedJSObject.console.log("STREAM_" + counter); + }, 1); + } + ); + + info("Wait until we get the first resource"); + await waitFor(() => gotTabResource); + + info("Disable multiprocess debugging"); + await pushPref("devtools.browsertoolbox.scope", "parent-process"); + + info("Wait for the tab target to be destroyed"); + await waitFor(() => windowGlobalTarget.isDestroyed()); + + info("Wait for a bit so any throttled action would have the time to occur"); + await wait(1000); + + // Stop listening for resources + await commands.resourceCommand.unwatchResources( + [commands.resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: onResourceAvailable, + } + ); + // And stop the interval + await SpecialPowers.spawn(firstTab.linkedBrowser, [intervalId], id => { + content.wrappedJSObject.clearInterval(id); + }); + + targetCommand.destroy(); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js b/devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js new file mode 100644 index 0000000000..dab6c8d8cc --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around SERVER SENT EVENTS. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const targets = { + TOP_LEVEL_DOCUMENT: "top-level-document", + IN_PROCESS_IFRAME: "in-process-frame", + OUT_PROCESS_IFRAME: "out-process-frame", +}; + +add_task(async function () { + info("Testing the top-level document"); + await testServerSentEventResources(targets.TOP_LEVEL_DOCUMENT); + info("Testing the in-process iframe"); + await testServerSentEventResources(targets.IN_PROCESS_IFRAME); + info("Testing the out-of-process iframe"); + await testServerSentEventResources(targets.OUT_PROCESS_IFRAME); +}); + +async function testServerSentEventResources(target) { + const tab = await addTab(URL_ROOT_SSL + "sse_frontend.html"); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const availableResources = []; + + function onResourceAvailable(resources) { + availableResources.push(...resources); + } + + await resourceCommand.watchResources( + [resourceCommand.TYPES.SERVER_SENT_EVENT], + { onAvailable: onResourceAvailable } + ); + + openConnectionInContext(tab, target); + + info("Check available resources"); + // We expect only 2 resources + await waitUntil(() => availableResources.length === 2); + + info("Check resource details"); + // To make sure the channel id are the same + const httpChannelId = availableResources[0].httpChannelId; + + ok(httpChannelId, "The channel id is set"); + is(typeof httpChannelId, "number", "The channel id is a number"); + + assertResource(availableResources[0], { + messageType: "eventReceived", + httpChannelId, + data: { + payload: "Why so serious?", + eventName: "message", + lastEventId: "", + retry: 5000, + }, + }); + + assertResource(availableResources[1], { + messageType: "eventSourceConnectionClosed", + httpChannelId, + }); + + await resourceCommand.unwatchResources( + [resourceCommand.TYPES.SERVER_SENT_EVENT], + { onAvailable: onResourceAvailable } + ); + + await targetCommand.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +} + +function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.SERVER_SENT_EVENT, + "Resource type is correct" + ); + + checkObject(resource, expected); +} + +async function openConnectionInContext(tab, target) { + let browsingContext = tab.linkedBrowser.browsingContext; + if (target !== targets.TOP_LEVEL_DOCUMENT) { + browsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [target], + async _target => { + const iframe = content.document.getElementById(_target); + return iframe.browsingContext; + } + ); + } + await SpecialPowers.spawn(browsingContext, [], async () => { + await content.wrappedJSObject.openConnection(); + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_several_resources.js b/devtools/shared/commands/resource/tests/browser_resources_several_resources.js new file mode 100644 index 0000000000..c1a151e562 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_several_resources.js @@ -0,0 +1,111 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that the resource command is still properly watching for new targets + * after unwatching one resource, if there is still another watched resource. + */ +add_task(async function () { + // We will create a main process target list here in order to monitor + // resources from new tabs as they get created. + await pushPref("devtools.browsertoolbox.scope", "everything"); + + // Open a test tab + const tab = await addTab("data:text/html,Root Node tests"); + + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + const { CONSOLE_MESSAGE, ROOT_NODE } = resourceCommand.TYPES; + + // We are only interested in console messages as a resource, the ROOT_NODE one + // is here to test the ResourceCommand::unwatchResources API with several resources. + const receivedMessages = []; + const onAvailable = resources => { + for (const resource of resources) { + if (resource.resourceType === CONSOLE_MESSAGE) { + receivedMessages.push(resource); + } + } + }; + + info("Call watchResources([CONSOLE_MESSAGE, ROOT_NODE], ...)"); + await resourceCommand.watchResources([CONSOLE_MESSAGE, ROOT_NODE], { + onAvailable, + }); + + info("Use console.log in the content page"); + logInTab(tab, "test from data-url"); + info( + "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the data-url tab" + ); + await waitUntil(() => + receivedMessages.find( + resource => resource.message.arguments[0] === "test from data-url" + ) + ); + + // Check that the resource command captures resources from new targets. + info("Open a first tab on the example.com domain"); + const comTab = await addTab( + "https://example.com/document-builder.sjs?html=com" + ); + info("Use console.log in the example.com page"); + logInTab(comTab, "test-from-example-com"); + info( + "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the example.com tab" + ); + await waitUntil(() => + receivedMessages.find( + resource => resource.message.arguments[0] === "test-from-example-com" + ) + ); + + info("Stop watching ROOT_NODE resources"); + await resourceCommand.unwatchResources([ROOT_NODE], { onAvailable }); + + // Check that messages from new targets are still captured after calling + // unwatch for another resource. + info("Open a second tab on the example.org domain"); + const orgTab = await addTab( + "https://example.org/document-builder.sjs?html=org" + ); + info("Use console.log in the example.org page"); + logInTab(orgTab, "test-from-example-org"); + info( + "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the example.org tab" + ); + await waitUntil(() => + receivedMessages.find( + resource => resource.message.arguments[0] === "test-from-example-org" + ) + ); + + info("Stop watching CONSOLE_MESSAGE resources"); + await resourceCommand.unwatchResources([CONSOLE_MESSAGE], { onAvailable }); + await logInTab(tab, "test-again"); + + // We don't have a specific event to wait for here, so allow some time for + // the message to be received. + await wait(1000); + + is( + receivedMessages.find( + resource => resource.message.arguments[0] === "test-again" + ), + undefined, + "The resource command should not watch CONSOLE_MESSAGE anymore" + ); + + // Cleanup + targetCommand.destroy(); + await client.close(); +}); + +function logInTab(tab, message) { + return ContentTask.spawn(tab.linkedBrowser, message, function (_message) { + content.console.log(_message); + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_sources.js b/devtools/shared/commands/resource/tests/browser_resources_sources.js new file mode 100644 index 0000000000..767f45283a --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_sources.js @@ -0,0 +1,456 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around SOURCE. +// +// We cover each Spidermonkey Debugger Source's `introductionType`: +// https://searchfox.org/mozilla-central/rev/4c184ca81b28f1ccffbfd08f465709b95bcb4aa1/js/src/doc/Debugger/Debugger.Source.md#172-213 +// +// And especially cover sources being GC-ed before DevTools are opened +// which are later recreated by `ThreadActor.resurrectSource`. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const TEST_URL = URL_ROOT_SSL + "sources.html"; + +const TEST_JS_URL = URL_ROOT_SSL + "sources.js"; +const TEST_WORKER_URL = URL_ROOT_SSL + "worker-sources.js"; +const TEST_SW_URL = URL_ROOT_SSL + "service-worker-sources.js"; + +async function getExpectedResources(ignoreUnresurrectedSources = false) { + const htmlRequest = await fetch(TEST_URL); + const htmlContent = await htmlRequest.text(); + + // First list sources that aren't GC-ed, or that the thread actor is able to resurrect + const expectedSources = [ + { + description: "eval", + sourceForm: { + introductionType: "eval", + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "this.global = function evalFunction() {}", + }, + }, + { + description: "new Function()", + sourceForm: { + introductionType: "Function", + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "function anonymous(\n) {\nreturn 42;\n}", + }, + }, + { + description: "Event Handler", + sourceForm: { + introductionType: "eventHandler", + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "console.log('link')", + }, + }, + { + description: "inline JS inserted at runtime", + sourceForm: { + introductionType: "scriptElement", // This is an injectedScript at SpiderMonkey level, but is translated into scriptElement by SourceActor.form() + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "console.log('inline-script')", + }, + }, + { + description: "inline JS", + sourceForm: { + introductionType: "scriptElement", // This is an inlineScript at SpiderMonkey level, but is translated into scriptElement by SourceActor.form() + sourceMapBaseURL: TEST_URL, + url: TEST_URL, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: true, + }, + sourceContent: { + contentType: "text/html", + source: htmlContent, + }, + }, + { + description: "worker script", + sourceForm: { + introductionType: undefined, + sourceMapBaseURL: TEST_WORKER_URL, + url: TEST_WORKER_URL, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction workerSource() {}\n", + }, + }, + { + description: "service worker script", + sourceForm: { + introductionType: undefined, + sourceMapBaseURL: TEST_SW_URL, + url: TEST_SW_URL, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction serviceWorkerSource() {}\n", + }, + }, + { + description: "independent js file", + sourceForm: { + introductionType: "scriptElement", // This is an srcScript at SpiderMonkey level, but is translated into scriptElement by SourceActor.form() + sourceMapBaseURL: TEST_JS_URL, + url: TEST_JS_URL, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction scriptSource() {}\n", + }, + }, + ]; + + // Now list the sources that could be GC-ed for which the thread actor isn't able to resurrect. + // This is the sources that we can't assert when we fetch sources after the page is already loaded. + const unresurrectedSources = [ + { + description: "DOM Timer", + sourceForm: { + introductionType: "domTimer", + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + /* the domTimer is prefixed by many empty lines in order to be positioned at the same line + as in the HTML file where setTimeout is called. + This is probably done by SourceActor.actualText(). + So the array size here, should be updated to match the line number of setTimeout call */ + source: new Array(39).join("\n") + `console.log("timeout")`, + }, + }, + { + description: "javascript URL", + sourceForm: { + introductionType: "javascriptURL", + sourceMapBaseURL: isEveryFrameTargetEnabled() + ? "about:blank" + : TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "666", + }, + }, + { + description: "srcdoc attribute on iframes #1", + sourceForm: { + introductionType: "scriptElement", + // We do not assert url/sourceMapBaseURL as it includes the Debugger.Source.id + // which is random + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "console.log('srcdoc')", + }, + }, + { + description: "srcdoc attribute on iframes #2", + sourceForm: { + introductionType: "scriptElement", + // We do not assert url/sourceMapBaseURL as it includes the Debugger.Source.id + // which is random + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "console.log('srcdoc 2')", + }, + }, + ]; + + if (ignoreUnresurrectedSources) { + return expectedSources; + } + return expectedSources.concat(unresurrectedSources); +} + +add_task(async function testSourcesOnload() { + // Load an blank document first, in order to load the test page only once we already + // started watching for sources + const tab = await addTab("about:blank"); + + const commands = await CommandsFactory.forTab(tab); + const { targetCommand, resourceCommand } = commands; + + // Force the target list to cover workers and debug all the targets + targetCommand.listenForWorkers = true; + targetCommand.listenForServiceWorkers = true; + await targetCommand.startListening(); + + info("Check already available resources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.SOURCE], { + onAvailable: resources => availableResources.push(...resources), + }); + + const promiseLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + TEST_URL + ); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, TEST_URL); + await promiseLoad; + + // Some sources may be created after the document is done loading (like eventHandler usecase) + // so we may be received *after* watchResource resolved + const expectedResources = await getExpectedResources(); + await waitFor( + () => availableResources.length >= expectedResources.length, + "Got all the sources" + ); + + await assertResources(availableResources, expectedResources); + + await commands.destroy(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +add_task(async function testGarbagedCollectedSources() { + info( + "Assert SOURCES on an already loaded page with some sources that have been GC-ed" + ); + const tab = await addTab(TEST_URL); + + info("Force some GC to free some sources"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + Cu.forceGC(); + Cu.forceCC(); + }); + + const commands = await CommandsFactory.forTab(tab); + const { targetCommand, resourceCommand } = commands; + + // Force the target list to cover workers and debug all the targets + targetCommand.listenForWorkers = true; + targetCommand.listenForServiceWorkers = true; + await targetCommand.startListening(); + + info("Check already available resources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.SOURCE], { + onAvailable: resources => availableResources.push(...resources), + }); + + // Some sources may be created after the document is done loading (like eventHandler usecase) + // so we may be received *after* watchResource resolved + const expectedResources = await getExpectedResources(true); + await waitFor( + () => availableResources.length >= expectedResources.length, + "Got all the sources" + ); + + await assertResources(availableResources, expectedResources); + + await commands.destroy(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +/** + * Assert that evaluating sources for a new global, in the parent process + * using the shared system principal will spawn SOURCE resources. + * + * For this we use a special `commands` which replicate what browser console + * and toolbox use. + */ +add_task(async function testParentProcessPrivilegedSources() { + // Use a custom loader + server + client in order to spawn the server + // in a distinct system compartment, so that it can see the system compartment + // sandbox we are about to create in this test + const client = await CommandsFactory.spawnClientToDebugSystemPrincipal(); + + const commands = await CommandsFactory.forMainProcess({ client }); + await commands.targetCommand.startListening(); + const { resourceCommand } = commands; + + info("Check already available resources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.SOURCE], { + onAvailable: resources => availableResources.push(...resources), + }); + ok( + !!availableResources.length, + "We get many sources reported from a multiprocess command" + ); + + // Clear the list of sources + availableResources.length = 0; + + // Force the creation of a new privileged source + const systemPrincipal = Cc["@mozilla.org/systemprincipal;1"].createInstance( + Ci.nsIPrincipal + ); + const sandbox = Cu.Sandbox(systemPrincipal); + Cu.evalInSandbox("function foo() {}", sandbox, null, "http://foo.com"); + + info("Wait for the sandbox source"); + await waitFor(() => { + return availableResources.some( + resource => resource.url == "http://foo.com/" + ); + }); + + const expectedResources = [ + { + description: "privileged sandbox script", + sourceForm: { + introductionType: undefined, + sourceMapBaseURL: "http://foo.com/", + url: "http://foo.com/", + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "function foo() {}", + }, + }, + ]; + const matchingResource = availableResources.filter(resource => + resource.url.includes("http://foo.com") + ); + await assertResources(matchingResource, expectedResources); + + await commands.destroy(); +}); + +async function assertResources(resources, expected) { + is( + resources.length, + expected.length, + "Length of existing resources is correct at initial" + ); + for (let i = 0; i < resources.length; i++) { + await assertResource(resources[i], expected); + } +} + +async function assertResource(source, expected) { + is( + source.resourceType, + ResourceCommand.TYPES.SOURCE, + "Resource type is correct" + ); + + const threadFront = await source.targetFront.getFront("thread"); + // `source` is SourceActor's form() + // so try to instantiate the related SourceFront: + const sourceFront = threadFront.source(source); + // then fetch source content + const sourceContent = await sourceFront.source(); + + // Order of sources is random, so we have to find the best expected resource. + // The only unique attribute is the JS Source text content. + const matchingExpected = expected.find(res => { + return res.sourceContent.source == sourceContent.source; + }); + ok( + matchingExpected, + `This source was expected with source content being "${sourceContent.source}"` + ); + info(`Found "#${matchingExpected.description}"`); + assertObject( + sourceContent, + matchingExpected.sourceContent, + matchingExpected.description + ); + + assertObject( + source, + matchingExpected.sourceForm, + matchingExpected.description + ); +} + +function assertObject(object, expected, description) { + for (const field in expected) { + is( + object[field], + expected[field], + `The value of ${field} is correct for "#${description}"` + ); + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets.js new file mode 100644 index 0000000000..ec81e8118d --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets.js @@ -0,0 +1,713 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around STYLESHEET. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const STYLE_TEST_URL = URL_ROOT_SSL + "style_document.html"; + +const EXISTING_RESOURCES = [ + { + styleText: "body { color: lime; }", + href: null, + nodeHref: + "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], + }, + { + styleText: "body { margin: 1px; }", + href: "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.css", + nodeHref: + "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], + }, + { + styleText: "", + href: null, + nodeHref: null, + isNew: false, + disabled: false, + constructed: true, + ruleCount: 1, + atRules: [], + }, + { + styleText: "body { background-color: pink; }", + href: null, + nodeHref: + "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], + }, + { + styleText: "body { padding: 1px; }", + href: "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.css", + nodeHref: + "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], + }, +]; + +const ADDITIONAL_INLINE_RESOURCE = { + styleText: + "@media all { body { color: red; } } @media print { body { color: cyan; } } body { font-size: 10px; }", + href: null, + nodeHref: + "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 5, + atRules: [ + { + type: "media", + conditionText: "all", + matches: true, + line: 1, + column: 1, + }, + { + type: "media", + conditionText: "print", + matches: false, + line: 1, + column: 37, + }, + ], +}; + +const ADDITIONAL_CONSTRUCTED_RESOURCE = { + styleText: "", + href: null, + nodeHref: null, + isNew: false, + disabled: false, + constructed: true, + ruleCount: 2, + atRules: [], +}; + +const ADDITIONAL_FROM_ACTOR_RESOURCE = { + styleText: "body { font-size: 10px; }", + href: null, + nodeHref: + "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html", + isNew: true, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], +}; + +add_task(async function () { + await testResourceAvailableDestroyedFeature(); + await testResourceUpdateFeature(); + await testNestedResourceUpdateFeature(); +}); + +function pushAvailableResource(availableResources) { + // TODO(bug 1826538): Find a better way of dealing with these. + return function (resources) { + for (const resource of resources) { + if (resource.href?.startsWith("resource://")) { + continue; + } + availableResources.push(resource); + } + }; +} + +async function testResourceAvailableDestroyedFeature() { + info("Check resource available feature of the ResourceCommand"); + + const tab = await addTab(STYLE_TEST_URL); + let resourceTimingEntryCounts = await getResourceTimingCount(tab); + is( + resourceTimingEntryCounts, + 2, + "Should have two entires for resource timing" + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check whether ResourceCommand gets existing stylesheet"); + const availableResources = []; + const destroyedResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: pushAvailableResource(availableResources), + onDestroyed: resources => destroyedResources.push(...resources), + }); + + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + for (let i = 0; i < availableResources.length; i++) { + const availableResource = availableResources[i]; + // We can not expect the resources to always be forwarded in the same order. + // See intermittent Bug 1655016. + const expectedResource = findMatchingExpectedResource(availableResource); + ok(expectedResource, "Found a matching expected resource for the resource"); + await assertResource(availableResource, expectedResource); + } + + resourceTimingEntryCounts = await getResourceTimingCount(tab); + is( + resourceTimingEntryCounts, + 2, + "Should still have two entires for resource timing after devtools APIs have been triggered" + ); + + info("Check whether ResourceCommand gets additonal stylesheet"); + await ContentTask.spawn( + tab.linkedBrowser, + ADDITIONAL_INLINE_RESOURCE.styleText, + text => { + const document = content.document; + const stylesheet = document.createElement("style"); + stylesheet.id = "inline-from-test"; + stylesheet.textContent = text; + document.body.appendChild(stylesheet); + } + ); + await waitUntil( + () => availableResources.length === EXISTING_RESOURCES.length + 1 + ); + await assertResource( + availableResources[availableResources.length - 1], + ADDITIONAL_INLINE_RESOURCE + ); + + info("Check whether ResourceCommand gets additonal constructed stylesheet"); + await ContentTask.spawn(tab.linkedBrowser, null, () => { + const document = content.document; + const s = new content.CSSStyleSheet(); + // We use the different number of rules to meaningfully differentiate + // between constructed stylesheets. + s.replaceSync("foo { color: red } bar { color: blue }"); + // TODO(bug 1751346): wrappedJSObject should be unnecessary. + document.wrappedJSObject.adoptedStyleSheets.push(s); + }); + await waitUntil( + () => availableResources.length === EXISTING_RESOURCES.length + 2 + ); + await assertResource( + availableResources[availableResources.length - 1], + ADDITIONAL_CONSTRUCTED_RESOURCE + ); + + info( + "Check whether ResourceCommand gets additonal stylesheet which is added by DevTools" + ); + const styleSheetsFront = await targetCommand.targetFront.getFront( + "stylesheets" + ); + await styleSheetsFront.addStyleSheet( + ADDITIONAL_FROM_ACTOR_RESOURCE.styleText + ); + await waitUntil( + () => availableResources.length === EXISTING_RESOURCES.length + 3 + ); + await assertResource( + availableResources[availableResources.length - 1], + ADDITIONAL_FROM_ACTOR_RESOURCE + ); + + info("Check resource destroyed feature of the ResourceCommand"); + is(destroyedResources.length, 0, "There was no removed stylesheets yet"); + + info("Remove inline stylesheet added in the test"); + await ContentTask.spawn(tab.linkedBrowser, null, () => { + content.document.querySelector("#inline-from-test").remove(); + }); + await waitUntil(() => destroyedResources.length === 1); + assertDestroyed(destroyedResources[0], { + resourceId: availableResources.at(-3).resourceId, + }); + + info("Remove existing top-level inline stylesheet"); + await ContentTask.spawn(tab.linkedBrowser, null, () => { + content.document.querySelector("style").remove(); + }); + await waitUntil(() => destroyedResources.length === 2); + assertDestroyed(destroyedResources[1], { + resourceId: availableResources.find( + resource => + findMatchingExpectedResource(resource) === EXISTING_RESOURCES[0] + ).resourceId, + }); + + info("Remove existing top-level <link> stylesheet"); + await ContentTask.spawn(tab.linkedBrowser, null, () => { + content.document.querySelector("link").remove(); + }); + await waitUntil(() => destroyedResources.length === 3); + assertDestroyed(destroyedResources[2], { + resourceId: availableResources.find( + resource => + findMatchingExpectedResource(resource) === EXISTING_RESOURCES[1] + ).resourceId, + }); + + info("Remove existing iframe inline stylesheet"); + const iframeBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); + + await SpecialPowers.spawn(iframeBrowsingContext, [], () => { + content.document.querySelector("style").remove(); + }); + await waitUntil(() => destroyedResources.length === 4); + assertDestroyed(destroyedResources[3], { + resourceId: availableResources.find( + resource => + findMatchingExpectedResource(resource) === EXISTING_RESOURCES[3] + ).resourceId, + }); + + info("Remove existing iframe <link> stylesheet"); + await SpecialPowers.spawn(iframeBrowsingContext, [], () => { + content.document.querySelector("link").remove(); + }); + await waitUntil(() => destroyedResources.length === 5); + assertDestroyed(destroyedResources[4], { + resourceId: availableResources.find( + resource => + findMatchingExpectedResource(resource) === EXISTING_RESOURCES[4] + ).resourceId, + }); + + targetCommand.destroy(); + await client.close(); +} + +async function testResourceUpdateFeature() { + info("Check resource update feature of the ResourceCommand"); + + const tab = await addTab(STYLE_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Setup the watcher"); + const availableResources = []; + const updates = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: pushAvailableResource(availableResources), + onUpdated: newUpdates => updates.push(...newUpdates), + }); + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + is(updates.length, 0, "there's no update yet"); + + info("Check toggleDisabled function"); + // Retrieve the stylesheet of the top-level target + const resource = availableResources.find( + innerResource => innerResource.targetFront.isTopLevel + ); + const styleSheetsFront = await resource.targetFront.getFront("stylesheets"); + await styleSheetsFront.toggleDisabled(resource.resourceId); + await waitUntil(() => updates.length === 1); + + // Check the content of the update object. + assertUpdate(updates[0].update, { + resourceId: resource.resourceId, + updateType: "property-change", + }); + is( + updates[0].update.resourceUpdates.disabled, + true, + "resourceUpdates is correct" + ); + + // Check whether the cached resource is updated correctly. + is( + updates[0].resource.disabled, + true, + "cached resource is updated correctly" + ); + + // Check whether the actual stylesheet is updated correctly. + const styleSheetDisabled = await ContentTask.spawn( + tab.linkedBrowser, + null, + () => { + const document = content.document; + const stylesheet = document.styleSheets[0]; + return stylesheet.disabled; + } + ); + is(styleSheetDisabled, true, "actual stylesheet was updated correctly"); + + info("Check update function"); + const expectedAtRules = [ + { + type: "media", + conditionText: "screen", + matches: true, + }, + { + type: "media", + conditionText: "print", + matches: false, + }, + ]; + + const updateCause = "updated-by-test"; + await styleSheetsFront.update( + resource.resourceId, + "@media screen { color: red; } @media print { color: green; } body { color: cyan; }", + false, + updateCause + ); + await waitUntil(() => updates.length === 4); + + assertUpdate(updates[1].update, { + resourceId: resource.resourceId, + updateType: "property-change", + }); + is( + updates[1].update.resourceUpdates.ruleCount, + 3, + "resourceUpdates is correct" + ); + is(updates[1].resource.ruleCount, 3, "cached resource is updated correctly"); + + assertUpdate(updates[2].update, { + resourceId: resource.resourceId, + updateType: "style-applied", + event: { + cause: updateCause, + }, + }); + is( + updates[2].update.resourceUpdates, + undefined, + "resourceUpdates is correct" + ); + + assertUpdate(updates[3].update, { + resourceId: resource.resourceId, + updateType: "at-rules-changed", + }); + assertAtRules(updates[3].update.resourceUpdates.atRules, expectedAtRules); + + // Check the actual page. + const styleSheetResult = await getStyleSheetResult(tab); + + is( + styleSheetResult.ruleCount, + 3, + "ruleCount of actual stylesheet is updated correctly" + ); + assertAtRules(styleSheetResult.atRules, expectedAtRules); + + targetCommand.destroy(); + await client.close(); +} + +async function testNestedResourceUpdateFeature() { + info("Check nested resource update feature of the ResourceCommand"); + + const tab = await addTab(STYLE_TEST_URL); + + const { outerWidth: originalWindowWidth, outerHeight: originalWindowHeight } = + tab.ownerGlobal; + + registerCleanupFunction(() => { + tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight); + }); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Setup the watcher"); + const availableResources = []; + const updates = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: pushAvailableResource(availableResources), + onUpdated: newUpdates => updates.push(...newUpdates), + }); + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + + info("Apply new media query"); + // In order to avoid applying the media query (min-height: 400px). + if (originalWindowHeight !== 300) { + await new Promise(resolve => { + tab.ownerGlobal.addEventListener("resize", resolve, { once: true }); + tab.ownerGlobal.resizeTo(originalWindowWidth, 300); + }); + } + + // Retrieve the stylesheet of the top-level target + const resource = availableResources.find( + innerResource => innerResource.targetFront.isTopLevel + ); + const styleSheetsFront = await resource.targetFront.getFront("stylesheets"); + await styleSheetsFront.update( + resource.resourceId, + `@media (min-height: 400px) { + html { + color: red; + } + @layer myLayer { + @supports (container-type) { + :root { + color: gold; + container: root inline-size; + } + + @container root (width > 10px) { + body { + color: gold; + } + } + } + } + }`, + false + ); + await waitUntil(() => updates.length === 3); + is( + updates.at(-1).resource.ruleCount, + 7, + "Resource in update has expected ruleCount" + ); + + is(resource.atRules[0].matches, false, "Media query is not matched yet"); + + info("Change window size to fire matches-change event"); + tab.ownerGlobal.resizeTo(originalWindowWidth, 500); + await waitUntil(() => updates.length === 4); + + // Check the update content. + const targetUpdate = updates[3]; + assertUpdate(targetUpdate.update, { + resourceId: resource.resourceId, + updateType: "matches-change", + }); + Assert.strictEqual( + resource, + targetUpdate.resource, + "Update object has the same resource" + ); + + is( + JSON.stringify(targetUpdate.update.nestedResourceUpdates[0].path), + JSON.stringify(["atRules", 0, "matches"]), + "path of nestedResourceUpdates is correct" + ); + is( + targetUpdate.update.nestedResourceUpdates[0].value, + true, + "value of nestedResourceUpdates is correct" + ); + + // Check the resource. + const expectedAtRules = [ + { + type: "media", + conditionText: "(min-height: 400px)", + matches: true, + }, + { + type: "layer", + layerName: "myLayer", + }, + { + type: "support", + conditionText: "(container-type)", + }, + { + type: "container", + conditionText: "root (width > 10px)", + }, + ]; + + assertAtRules(targetUpdate.resource.atRules, expectedAtRules); + + // Check the actual page. + const styleSheetResult = await getStyleSheetResult(tab); + is( + styleSheetResult.ruleCount, + 7, + "ruleCount of actual stylesheet is updated correctly" + ); + assertAtRules(styleSheetResult.atRules, expectedAtRules); + + tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight); + + targetCommand.destroy(); + await client.close(); +} + +function findMatchingExpectedResource(resource) { + return EXISTING_RESOURCES.find( + expected => + resource.href === expected.href && + resource.nodeHref === expected.nodeHref && + resource.ruleCount === expected.ruleCount && + resource.constructed == expected.constructed + ); +} + +async function getStyleSheetResult(tab) { + const result = await ContentTask.spawn(tab.linkedBrowser, null, () => { + const document = content.document; + const stylesheet = document.styleSheets[0]; + let ruleCount = 0; + const atRules = []; + + const traverseRules = ruleList => { + for (const rule of ruleList) { + ruleCount++; + + if (rule.media) { + let matches = false; + try { + const mql = content.matchMedia(rule.media.mediaText); + matches = mql.matches; + } catch (e) { + // Ignored + } + + atRules.push({ + type: "media", + conditionText: rule.conditionText, + matches, + }); + } else if (rule instanceof content.CSSContainerRule) { + atRules.push({ + type: "container", + conditionText: rule.conditionText, + }); + } else if (rule instanceof content.CSSLayerBlockRule) { + atRules.push({ type: "layer", layerName: rule.name }); + } else if (rule instanceof content.CSSSupportsRule) { + atRules.push({ + type: "support", + conditionText: rule.conditionText, + }); + } + + if (rule.cssRules) { + traverseRules(rule.cssRules); + } + } + }; + traverseRules(stylesheet.cssRules); + + return { ruleCount, atRules }; + }); + + return result; +} + +function assertAtRules(atRules, expectedAtRules) { + is( + atRules.length, + expectedAtRules.length, + "Length of the atRules is correct" + ); + + for (let i = 0; i < atRules.length; i++) { + const atRule = atRules[i]; + const expected = expectedAtRules[i]; + is(atRule.type, expected.type, "at-rule is of expected type"); + is( + atRules[i].conditionText, + expected.conditionText, + "conditionText is correct" + ); + if (expected.type === "media") { + is(atRule.matches, expected.matches, "matches is correct"); + } else if (expected.type === "layer") { + is(atRule.layerName, expected.layerName, "layerName is correct"); + } + + if (expected.line !== undefined) { + is(atRule.line, expected.line, "line is correct"); + } + + if (expected.column !== undefined) { + is(atRule.column, expected.column, "column is correct"); + } + } +} + +async function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.STYLESHEET, + "Resource type is correct" + ); + const styleText = (await getStyleSheetResourceText(resource)).trim(); + is(styleText, expected.styleText, "Style text is correct"); + is(resource.href, expected.href, "href is correct"); + is(resource.nodeHref, expected.nodeHref, "nodeHref is correct"); + is(resource.isNew, expected.isNew, "isNew is correct"); + is(resource.disabled, expected.disabled, "disabled is correct"); + is(resource.constructed, expected.constructed, "constructed is correct"); + is(resource.ruleCount, expected.ruleCount, "ruleCount is correct"); + assertAtRules(resource.atRules, expected.atRules); +} + +function assertUpdate(update, expected) { + is( + update.resourceType, + ResourceCommand.TYPES.STYLESHEET, + "Resource type is correct" + ); + is(update.resourceId, expected.resourceId, "resourceId is correct"); + is(update.updateType, expected.updateType, "updateType is correct"); + if (expected.event?.cause) { + is(update.event?.cause, expected.event.cause, "cause is correct"); + } +} + +function assertDestroyed(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.STYLESHEET, + "Resource type is correct" + ); + is(resource.resourceId, expected.resourceId, "resourceId is correct"); +} + +function getResourceTimingCount(tab) { + return ContentTask.spawn(tab.linkedBrowser, [], () => { + return content.performance.getEntriesByType("resource").length; + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_header.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_header.js new file mode 100644 index 0000000000..29263d887b --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_header.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we do get the appropriate stylesheet content when the stylesheet is only +// served based on the Accept: text/css header + +add_task(async function () { + const httpServer = createTestHTTPServer(); + + httpServer.registerContentType("html", "text/html"); + + httpServer.registerPathHandler("/index.html", function (request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write(` +<!DOCTYPE html> +<meta charset="utf-8"> +<title>Test stylesheet</title> +<link href="/test/" rel="stylesheet" type="text/css"/> +<script src="/test/"></script> +<h1>Hello</h1> + `); + }); + + let resourceUrlCalls = 0; + // The /test/ URL should be called: + // - once by the content page to load the <link> + // - once by the content page to load the <script> + // - once by DevTools to fetch the stylesheet text + // (we could probably optimize this so we only call once) + const expectedResourceUrlCalls = 3; + + const styleSheetText = `body { background-color: tomato; }`; + httpServer.registerPathHandler("/test/", function (request, response) { + resourceUrlCalls++; + response.setStatusLine(request.httpVersion, 200, "OK"); + + if (request.getHeader("Accept").startsWith("text/css")) { + response.setHeader("Content-Type", "text/css", false); + response.write(styleSheetText); + return; + } + response.setHeader("Content-Type", "application/javascript", false); + response.write(`/* NOT A STYLESHEET */`); + }); + const port = httpServer.identity.primaryPort; + const TEST_URL = `http://localhost:${port}/index.html`; + + info("Check resource available feature of the ResourceCommand"); + const tab = await addTab(TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check whether ResourceCommand gets existing stylesheet"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + }); + is( + availableResources.length, + 1, + "We have the expected number of stylesheets" + ); + + is( + await getStyleSheetResourceText(availableResources[0]), + styleSheetText, + "Got expected text for the stylesheet" + ); + + is( + resourceUrlCalls, + expectedResourceUrlCalls, + "The /test URL was called the number of time we expected" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js new file mode 100644 index 0000000000..c58a5162e0 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API for imported STYLESHEET + iframe. + +const styleSheetText = ` +@import "${URL_ROOT_ORG_SSL}/style_document.css"; +body { background-color: tomato; }`; + +const IFRAME_URL = `https://example.org/document-builder.sjs?html=${encodeURIComponent(` + <style>${styleSheetText}</style> + <h1>iframe</h1> +`)}`; + +const TEST_URL = `https://example.org/document-builder.sjs?html= + <h1>import stylesheet test</h1> + <iframe src="${encodeURIComponent(IFRAME_URL)}"></iframe>`; + +add_task(async function () { + info("Check resource available feature of the ResourceCommand"); + + const tab = await addTab(TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check whether ResourceCommand gets existing stylesheet"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + }); + + await waitFor(() => availableResources.length === 2); + ok(true, "We're getting the expected stylesheets"); + + const styleNodeStyleSheet = availableResources.find( + resource => resource.nodeHref + ); + const importedStyleSheet = availableResources.find( + resource => resource !== styleNodeStyleSheet + ); + + is( + await getStyleSheetResourceText(styleNodeStyleSheet), + styleSheetText, + "Got expected text for the <style> stylesheet" + ); + + is( + (await getStyleSheetResourceText(importedStyleSheet)).trim(), + `body { margin: 1px; }`, + "Got expected text for the imported stylesheet" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js new file mode 100644 index 0000000000..1ee8913bda --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js @@ -0,0 +1,254 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around STYLESHEET and navigation (reloading, creation of new browsing context, …) + +const ORG_DOC_BUILDER = "https://example.org/document-builder.sjs"; +const COM_DOC_BUILDER = "https://example.com/document-builder.sjs"; + +// Since the order of resources is not guaranteed, we put a number in the title attribute +// of the <style> elements so we can sort them in a way that makes it easier for us to assert. +let currentStyleTitle = 0; + +const TEST_URI = + `${ORG_DOC_BUILDER}?html=1<h1>top-level example.org</h1>` + + `<style title="${currentStyleTitle++}">.top-level-org{}</style>` + + `<iframe id="same-origin-1" src="${ORG_DOC_BUILDER}?html=<h2>example.org 1</h2><style title=${currentStyleTitle++}>.frame-org-1{}</style>"></iframe>` + + `<iframe id="same-origin-2" src="${ORG_DOC_BUILDER}?html=<h2>example.org 2</h2><style title=${currentStyleTitle++}>.frame-org-2{}</style>"></iframe>` + + `<iframe id="remote-origin-1" src="${COM_DOC_BUILDER}?html=<h2>example.com 1</h2><style title=${currentStyleTitle++}>.frame-com-1{}</style>"></iframe>` + + `<iframe id="remote-origin-2" src="${COM_DOC_BUILDER}?html=<h2>example.com 2</h2><style title=${currentStyleTitle++}>.frame-com-2{}</style>"></iframe>`; + +const COOP_HEADERS = "Cross-Origin-Opener-Policy:same-origin"; +const TEST_URI_NEW_BROWSING_CONTEXT = + `${ORG_DOC_BUILDER}?headers=${COOP_HEADERS}` + + `&html=<h1>top-level example.org</div>` + + `<style>.top-level-org-new-bc{}</style>`; + +add_task(async function () { + info( + "Open a new tab and check that styleSheetChangeEventsEnabled is false by default" + ); + const tab = await addTab(TEST_URI); + + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + false, + `styleSheetChangeEventsEnabled is false at the beginning` + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + let availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => { + availableResources.push(...resources); + }, + }); + + info("Wait for all the stylesheets resources of main document and iframes"); + await waitFor(() => availableResources.length === 5); + is(availableResources.length, 5, "Retrieved the expected stylesheets"); + + // the order of the resources is not guaranteed. + sortResourcesByExpectedOrder(availableResources); + await assertResource(availableResources[0], { + styleText: `.top-level-org{}`, + }); + await assertResource(availableResources[1], { + styleText: `.frame-org-1{}`, + }); + await assertResource(availableResources[2], { + styleText: `.frame-org-2{}`, + }); + await assertResource(availableResources[3], { + styleText: `.frame-com-1{}`, + }); + await assertResource(availableResources[4], { + styleText: `.frame-com-2{}`, + }); + + // clear availableResources so it's easier to test + availableResources = []; + + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + true, + `styleSheetChangeEventsEnabled is true after watching stylesheets` + ); + + info("Navigate a remote frame to a different page"); + const iframeNewUrl = + `https://example.com/document-builder.sjs?` + + `html=<h2>example.com new bc</h2><style title=6>.frame-com-new-bc{}</style>`; + await SpecialPowers.spawn(tab.linkedBrowser, [iframeNewUrl], url => { + const { browsingContext } = + content.document.querySelector("#remote-origin-2"); + return SpecialPowers.spawn(browsingContext, [url], innerUrl => { + content.document.location = innerUrl; + }); + }); + await waitFor(() => availableResources.length == 1); + ok(true, "We're notified about the iframe new document stylesheet"); + await assertResource(availableResources[0], { + styleText: `.frame-com-new-bc{}`, + }); + const iframeNewBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("#remote-origin-2").browsingContext + ); + + is( + await getDocumentStyleSheetChangeEventsEnabled(iframeNewBrowsingContext), + true, + `styleSheetChangeEventsEnabled is still true after navigating the iframe` + ); + + // clear availableResources so it's easier to test + availableResources = []; + + info("Check that styleSheetChangeEventsEnabled persist after reloading"); + await reloadBrowser(); + + // ⚠️ When EFT is disabled, we're only getting the stylesheets for the top-level document + // and the remote frames; the same-origin iframes stylesheets are missing. + const expectedStylesheetResources = isEveryFrameTargetEnabled() ? 5 : 3; + info( + "Wait until we're notified about all the stylesheets (top-level document + iframe)" + ); + await waitFor( + () => availableResources.length === expectedStylesheetResources + ); + is( + availableResources.length, + expectedStylesheetResources, + "Retrieved the expected stylesheets after the page was reloaded" + ); + + // the order of the resources is not guaranteed. + sortResourcesByExpectedOrder(availableResources); + await assertResource(availableResources[0], { + styleText: `.top-level-org{}`, + }); + if (isEveryFrameTargetEnabled()) { + await assertResource(availableResources[1], { + styleText: `.frame-org-1{}`, + }); + await assertResource(availableResources[2], { + styleText: `.frame-org-2{}`, + }); + await assertResource(availableResources[3], { + styleText: `.frame-com-1{}`, + }); + await assertResource(availableResources[4], { + styleText: `.frame-com-new-bc{}`, + }); + } else { + await assertResource(availableResources[1], { + styleText: `.frame-com-1{}`, + }); + await assertResource(availableResources[2], { + styleText: `.frame-com-new-bc{}`, + }); + } + + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + true, + `styleSheetChangeEventsEnabled is still true on the top level document after reloading` + ); + + if (isEveryFrameTargetEnabled()) { + const bc = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("#same-origin-1").browsingContext + ); + is( + await getDocumentStyleSheetChangeEventsEnabled(bc), + true, + `styleSheetChangeEventsEnabled is still true on the iframe after reloading` + ); + } + + // clear availableResources so it's easier to test + availableResources = []; + + info( + "Check that styleSheetChangeEventsEnabled persist when navigating to a page that creates a new browsing context" + ); + const previousBrowsingContextId = tab.linkedBrowser.browsingContext.id; + const onLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + TEST_URI_NEW_BROWSING_CONTEXT + ); + await onLoaded; + + isnot( + tab.linkedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + info("Wait to get the stylesheet for the new document"); + await waitFor(() => availableResources.length === 1); + ok(true, "We received the stylesheet for the new document"); + await assertResource(availableResources[0], { + styleText: `.top-level-org-new-bc{}`, + }); + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + true, + `styleSheetChangeEventsEnabled is still true after navigating to a new browsing context` + ); + + targetCommand.destroy(); + await client.close(); +}); + +/** + * Returns the value of the browser/browsingContext document `styleSheetChangeEventsEnabled` + * property. + * + * @param {Browser|BrowsingContext} browserOrBrowsingContext: The browser element or a + * browsing context. + * @returns {Promise<Boolean>} + */ +function getDocumentStyleSheetChangeEventsEnabled(browserOrBrowsingContext) { + return SpecialPowers.spawn(browserOrBrowsingContext, [], () => { + return content.document.styleSheetChangeEventsEnabled; + }); +} + +/** + * Sort the passed array of stylesheet resources. + * + * Since the order of resources are not guaranteed, the <style> elements we use in this test + * have a "title" attribute that represent their expected order so we can sort them in + * a way that makes it easier for us to assert. + * + * @param {Array<Object>} resources: Array of stylesheet resources + */ +function sortResourcesByExpectedOrder(resources) { + resources.sort((a, b) => { + return Number(a.title) > Number(b.title); + }); +} + +/** + * Check that the resources have the expected text + * + * @param {Array<Object>} resources: Array of stylesheet resources + * @param {Array<Object>} expected: Array of object of the following shape: + * @param {Object} expected[] + * @param {Object} expected[].styleText: Expected text content of the stylesheet + */ +async function assertResource(resource, expected) { + const styleText = (await getStyleSheetResourceText(resource)).trim(); + is(styleText, expected.styleText, "Style text is correct"); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js new file mode 100644 index 0000000000..0b13f75ab9 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that stylesheets are retrieved even if an iframe does not have a content document. + +const TEST_URI = URL_ROOT_SSL + "stylesheets-nested-iframes.html"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check whether ResourceCommand gets existing stylesheet"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + }); + + // Bug 285395 limits the number of nested iframes to 10, and we have one stylesheet per document. + await waitFor(() => availableResources.length >= 10); + + is( + availableResources.length, + 10, + "Got the expected number of stylesheets, even with documentless iframes" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_target_destroy.js b/devtools/shared/commands/resource/tests/browser_resources_target_destroy.js new file mode 100644 index 0000000000..fa7813d26e --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_target_destroy.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the server ResourceCommand are destroyed when the associated target actors +// are destroyed. + +add_task(async function () { + const tab = await addTab("data:text/html,Test"); + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // Start watching for console messages. We don't care about messages here, only the + // registration/destroy mechanism, so we make onAvailable a no-op function. + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: () => {}, + } + ); + + info( + "Spawn a content task in order to be able to manipulate actors and resource watchers directly" + ); + const connectionPrefix = targetCommand.watcherFront.actorID.replace( + /watcher\d+$/, + "" + ); + await ContentTask.spawn( + tab.linkedBrowser, + [connectionPrefix], + function (_connectionPrefix) { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { TargetActorRegistry } = ChromeUtils.importESModule( + "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs" + ); + const { + getResourceWatcher, + TYPES, + } = require("resource://devtools/server/actors/resources/index.js"); + + // Retrieve the target actor instance and its watcher for console messages + const targetActor = TargetActorRegistry.getTargetActors( + { + type: "browser-element", + browserId: content.browsingContext.browserId, + }, + _connectionPrefix + ).find(actor => actor.isTopLevelTarget); + ok( + targetActor, + "Got the top level target actor from the content process" + ); + const watcher = getResourceWatcher(targetActor, TYPES.CONSOLE_MESSAGE); + + // Storing the target actor in the global so we can retrieve it later, even if it + // was destroyed + content._testTargetActor = targetActor; + + is(!!watcher, true, "The console message resource watcher was created"); + } + ); + + info("Close the client, which will destroy the target"); + targetCommand.destroy(); + await client.close(); + + info( + "Spawn a content task in order to run some assertions on actors and resource watchers directly" + ); + await ContentTask.spawn(tab.linkedBrowser, [], function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + getResourceWatcher, + TYPES, + } = require("resource://devtools/server/actors/resources/index.js"); + + ok( + content._testTargetActor && !content._testTargetActor.actorID, + "The target was destroyed when the client was closed" + ); + + // Retrieve the console message resource watcher + const watcher = getResourceWatcher( + content._testTargetActor, + TYPES.CONSOLE_MESSAGE + ); + + is( + !!watcher, + false, + "The console message resource watcher isn't registered anymore after the target was destroyed" + ); + + // Cleanup work variable + delete content._testTargetActor; + }); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_target_resources_race.js b/devtools/shared/commands/resource/tests/browser_resources_target_resources_race.js new file mode 100644 index 0000000000..557d14380a --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_target_resources_race.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test initial target resources are correctly retrieved even when several calls + * to watchResources are made simultaneously. + * + * This checks a race condition which occurred when calling watchResources + * simultaneously. This made the "second" call to watchResources miss existing + * resources (in case those are emitted from the target instead of the watcher). + * See Bug 1663896. + */ +add_task(async function () { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + const expectedPlatformMessage = "expectedMessage"; + + info("Log a message *before* calling ResourceCommand.watchResources"); + Services.console.logStringMessage(expectedPlatformMessage); + + info("Call watchResources from 2 separate call sites consecutively"); + + // Empty onAvailable callback for CSS MESSAGES, we only want to check that + // the second resource we watch correctly provides existing resources. + const onCssMessageAvailable = resources => {}; + + // First call to watchResources. + // We do not await on `watchPromise1` here, in order to simulate simultaneous + // calls to watchResources (which could come from 2 separate modules in a real + // scenario). + const initialWatchPromise = resourceCommand.watchResources( + [resourceCommand.TYPES.CSS_MESSAGE], + { + onAvailable: onCssMessageAvailable, + } + ); + + // `waitForNextResource` will trigger another call to `watchResources`. + const { onResource: onMessageReceived } = + await resourceCommand.waitForNextResource( + resourceCommand.TYPES.PLATFORM_MESSAGE, + { + ignoreExistingResources: false, + predicate: r => r.message === expectedPlatformMessage, + } + ); + + info("Waiting for the expected message to be received"); + await onMessageReceived; + ok(true, "All the expected messages were received"); + + info("Wait for the other watchResources promise to finish"); + await initialWatchPromise; + + // Unwatch all resources. + resourceCommand.unwatchResources([resourceCommand.TYPES.CSS_MESSAGE], { + onAvailable: onCssMessageAvailable, + }); + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_target_switching.js b/devtools/shared/commands/resource/tests/browser_resources_target_switching.js new file mode 100644 index 0000000000..4551fec778 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_target_switching.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the behavior of ResourceCommand when the top level target changes + +const TEST_URI = + "data:text/html;charset=utf-8,<script>console.log('foo');</script>"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + const { CONSOLE_MESSAGE, SOURCE } = resourceCommand.TYPES; + + info("Check the resources gotten from getAllResources at initial"); + is( + resourceCommand.getAllResources(CONSOLE_MESSAGE).length, + 0, + "There is no resources before calling watchResources" + ); + + info( + "Start to watch the available resources in order to compare with resources gotten from getAllResources" + ); + const availableResources = []; + const onAvailable = resources => { + availableResources.push(...resources); + }; + await resourceCommand.watchResources([CONSOLE_MESSAGE], { onAvailable }); + + is(availableResources.length, 1, "Got the page message"); + is( + availableResources[0].message.arguments[0], + "foo", + "Got the expected page message" + ); + + // Register another listener before unregistering the console listener + // otherwise the resource command stop watching for targets + const onSourceAvailable = () => {}; + await resourceCommand.watchResources([SOURCE], { + onAvailable: onSourceAvailable, + }); + + info( + "Unregister the console listener and check that we no longer listen for console messages" + ); + resourceCommand.unwatchResources([CONSOLE_MESSAGE], { + onAvailable, + }); + + let onSwitched = targetCommand.once("switched-target"); + info("Navigate to another process"); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "about:robots" + ); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await onSwitched; + + is( + availableResources.length, + 1, + "about:robots doesn't fire any new message, so we should have a new one" + ); + + info("Navigate back to data: URI"); + onSwitched = targetCommand.once("switched-target"); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, TEST_URI); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await onSwitched; + + is( + availableResources.length, + 1, + "the data:URI fired a message, but we are no longer listening to it, so no new one should be notified" + ); + is( + resourceCommand.getAllResources(CONSOLE_MESSAGE).length, + 0, + "As we are no longer listening to CONSOLE message, we should not collect any" + ); + + resourceCommand.unwatchResources([SOURCE], { + onAvailable: onSourceAvailable, + }); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_thread_states.js b/devtools/shared/commands/resource/tests/browser_resources_thread_states.js new file mode 100644 index 0000000000..f915bb14d0 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_thread_states.js @@ -0,0 +1,557 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around THREAD_STATE + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const BREAKPOINT_TEST_URL = URL_ROOT_SSL + "breakpoint_document.html"; +const REMOTE_IFRAME_URL = + "https://example.org/document-builder.sjs?html=" + + encodeURIComponent("<script>debugger;</script>"); + +add_task(async function () { + // Check hitting the "debugger;" statement before and after calling + // watchResource(THREAD_TYPES). Both should break. First will + // be a cached resource and second will be a live one. + await checkBreakpointBeforeWatchResources(); + await checkBreakpointAfterWatchResources(); + + // Check setting a real breakpoint on a given line + await checkRealBreakpoint(); + + // Check the "pause on exception" setting + await checkPauseOnException(); + + // Check an edge case where spamming setBreakpoints calls causes issues + await checkSetBeforeWatch(); + + // Check debugger statement for (remote) iframes + await checkDebuggerStatementInIframes(); +}); + +async function checkBreakpointBeforeWatchResources() { + info( + "Check whether ResourceCommand gets existing breakpoint, being hit before calling watchResources" + ); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // Ensure that the target front is initialized early from TargetCommand.onTargetAvailable + // By the time `initResourceCommand` resolves, it should already be initialized. + info( + "Verify that TargetFront's initialized is resolved after having calling attachAndInitThread" + ); + await targetCommand.targetFront.initialized; + + info("Run the 'debugger' statement"); + // Note that we do not wait for the resolution of spawn as it will be paused + ContentTask.spawn(tab.linkedBrowser, null, () => { + content.window.wrappedJSObject.runDebuggerStatement(); + }); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 1, + "Got the THREAD_STATE's related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "debuggerStatement", + }, + frame: { + type: "call", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "runDebuggerStatement", + // arguments: [] + where: { + line: 17, + column: 6, + }, + }, + }); + + const { threadFront } = targetCommand.targetFront; + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function checkBreakpointAfterWatchResources() { + info( + "Check whether ResourceCommand gets breakpoint hit after calling watchResources" + ); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 0, + "Got no THREAD_STATE when calling watchResources" + ); + + info("Run the 'debugger' statement"); + // Note that we do not wait for the resolution of spawn as it will be paused + ContentTask.spawn(tab.linkedBrowser, null, () => { + content.window.wrappedJSObject.runDebuggerStatement(); + }); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "debuggerStatement", + }, + frame: { + type: "call", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "runDebuggerStatement", + // arguments: [] + where: { + line: 17, + column: 6, + }, + }, + }); + + // treadFront is created and attached while calling watchResources + const { threadFront } = targetCommand.targetFront; + + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function checkRealBreakpoint() { + info( + "Check whether ResourceCommand gets breakpoint set via the thread Front (instead of just debugger statements)" + ); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 0, + "Got no THREAD_STATE when calling watchResources" + ); + + // treadFront is created and attached while calling watchResources + const { threadFront } = targetCommand.targetFront; + + // We have to call `sources` request, otherwise the Thread Actor + // doesn't start watching for sources, and ignore the setBreakpoint call + // as it doesn't have any source registered. + await threadFront.getSources(); + + await threadFront.setBreakpoint( + { sourceUrl: BREAKPOINT_TEST_URL, line: 14 }, + {} + ); + + info("Run the test function where we set a breakpoint"); + // Note that we do not wait for the resolution of spawn as it will be paused + ContentTask.spawn(tab.linkedBrowser, null, () => { + content.window.wrappedJSObject.testFunction(); + }); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "breakpoint", + }, + frame: { + type: "call", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "testFunction", + // arguments: [] + where: { + line: 14, + column: 6, + }, + }, + }); + + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function checkPauseOnException() { + info( + "Check whether ResourceCommand gets breakpoint for exception (when explicitly requested)" + ); + + const tab = await addTab( + "data:text/html,<meta charset=utf8><script>a.b.c.d</script>" + ); + + const { commands, resourceCommand, targetCommand } = + await initResourceCommand(tab); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 0, + "Got no THREAD_STATE when calling watchResources" + ); + + await commands.threadConfigurationCommand.updateConfiguration({ + pauseOnExceptions: true, + }); + + info("Reload the page, in order to trigger exception on load"); + const reloaded = reloadBrowser(); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "exception", + }, + frame: { + type: "global", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "(global)", + // arguments: [] + where: { + line: 1, + column: 27, + }, + }, + }); + + const { threadFront } = targetCommand.targetFront; + await threadFront.resume(); + info("Wait for page to finish reloading after resume"); + await reloaded; + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await commands.destroy(); +} + +async function checkSetBeforeWatch() { + info( + "Verify bug 1683139 - D103068, where setting a breakpoint before watching for thread state, avoid receiving the paused state" + ); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // Instantiate the thread front in order to be able to set a breakpoint before watching for thread state + info("Attach the top level thread actor"); + await targetCommand.targetFront.attachAndInitThread(targetCommand); + const { threadFront } = targetCommand.targetFront; + + // We have to call `sources` request, otherwise the Thread Actor + // doesn't start watching for sources, and ignore the setBreakpoint call + // as it doesn't have any source registered. + await threadFront.getSources(); + + // Set the breakpoint before trying to hit it + await threadFront.setBreakpoint( + { sourceUrl: BREAKPOINT_TEST_URL, line: 14, column: 6 }, + {} + ); + + info("Run the test function where we set a breakpoint"); + // Note that we do not wait for the resolution of spawn as it will be paused + ContentTask.spawn(tab.linkedBrowser, null, () => { + content.window.wrappedJSObject.testFunction(); + }); + + // bug 1683139 - D103068. Re-setting the breakpoint just before watching for thread state + // prevented to receive the paused state change. + await threadFront.setBreakpoint( + { sourceUrl: BREAKPOINT_TEST_URL, line: 14, column: 6 }, + {} + ); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "breakpoint", + }, + frame: { + type: "call", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "testFunction", + // arguments: [] + where: { + line: 14, + column: 6, + }, + }, + }); + + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function checkDebuggerStatementInIframes() { + info("Check whether ResourceCommand gets breakpoint for (remote) iframes"); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 0, + "Got no THREAD_STATE when calling watchResources" + ); + + info("Inject the iframe with an inline 'debugger' statement"); + // Note that we do not wait for the resolution of spawn as it will be paused + SpecialPowers.spawn( + gBrowser.selectedBrowser, + [REMOTE_IFRAME_URL], + async function (url) { + const iframe = content.document.createElement("iframe"); + iframe.src = url; + content.document.body.appendChild(iframe); + } + ); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the iframe's debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "debuggerStatement", + }, + frame: { + type: "global", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "(global)", + // arguments: [] + where: { + line: 1, + column: 8, + }, + }, + }); + + const iframeTarget = threadState.targetFront; + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + is( + iframeTarget.url, + REMOTE_IFRAME_URL, + "With fission/EFT, the pause is from the iframe's target" + ); + } else { + is( + iframeTarget, + targetCommand.targetFront, + "Without fission/EFT, the pause is from the top level target" + ); + } + const { threadFront } = iframeTarget; + + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function assertPausedResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.THREAD_STATE, + "Resource type is correct" + ); + is(resource.state, "paused", "state attribute is correct"); + is(resource.why.type, expected.why.type, "why.type attribute is correct"); + is( + resource.frame.type, + expected.frame.type, + "frame.type attribute is correct" + ); + is( + resource.frame.asyncCause, + expected.frame.asyncCause, + "frame.asyncCause attribute is correct" + ); + is( + resource.frame.state, + expected.frame.state, + "frame.state attribute is correct" + ); + is( + resource.frame.displayName, + expected.frame.displayName, + "frame.displayName attribute is correct" + ); + is( + resource.frame.where.line, + expected.frame.where.line, + "frame.where.line attribute is correct" + ); + is( + resource.frame.where.column, + expected.frame.where.column, + "frame.where.column attribute is correct" + ); +} + +async function assertResumedResource(resource) { + is( + resource.resourceType, + ResourceCommand.TYPES.THREAD_STATE, + "Resource type is correct" + ); + is(resource.state, "resumed", "state attribute is correct"); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js b/devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js new file mode 100644 index 0000000000..e3890cf970 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that calling unwatchResources before watchResources could resolve still +// removes watcher entries correctly. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const TEST_URI = "data:text/html;charset=utf-8,"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + const { CONSOLE_MESSAGE, ROOT_NODE } = resourceCommand.TYPES; + + info("Use console.log in the content page"); + await logInTab(tab, "msg-1"); + + info("Call watchResources with various configurations"); + + // Watcher 1 only watches for CONSOLE_MESSAGE. + // For this call site, unwatchResource will be called before onAvailable has + // resolved. + const messages1 = []; + const onAvailable1 = createMessageCallback(messages1); + const onWatcher1Ready = resourceCommand.watchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable1, + }); + resourceCommand.unwatchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable1, + }); + + info( + "Calling unwatchResources for an already unregistered callback should be a no-op" + ); + // and more importantly, it should not throw + resourceCommand.unwatchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable1, + }); + + // Watcher 2 watches for CONSOLE_MESSAGE & another resource (ROOT_NODE). + // Again unwatchResource will be called before onAvailable has resolved. + // But unwatchResource is only called for CONSOLE_MESSAGE, not for ROOT_NODE. + const messages2 = []; + const onAvailable2 = createMessageCallback(messages2); + const onWatcher2Ready = resourceCommand.watchResources( + [CONSOLE_MESSAGE, ROOT_NODE], + { + onAvailable: onAvailable2, + } + ); + resourceCommand.unwatchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable2, + }); + + // Watcher 3 watches for CONSOLE_MESSAGE, but we will not call unwatchResource + // explicitly for it before the end of test. Used as a reference. + const messages3 = []; + const onAvailable3 = createMessageCallback(messages3); + const onWatcher3Ready = resourceCommand.watchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable3, + }); + + info("Call unwatchResources for CONSOLE_MESSAGE on watcher 1 & 2"); + + info("Wait for all watchers `watchResources` to resolve"); + await Promise.all([onWatcher1Ready, onWatcher2Ready, onWatcher3Ready]); + ok(!hasMessage(messages1, "msg-1"), "Watcher 1 did not receive msg-1"); + ok(!hasMessage(messages2, "msg-1"), "Watcher 2 did not receive msg-1"); + ok(hasMessage(messages3, "msg-1"), "Watcher 3 received msg-1"); + + info("Log a new message"); + await logInTab(tab, "msg-2"); + + info("Wait until watcher 3 received the new message"); + await waitUntil(() => hasMessage(messages3, "msg-2")); + + ok(!hasMessage(messages1, "msg-2"), "Watcher 1 did not receive msg-2"); + ok(!hasMessage(messages2, "msg-2"), "Watcher 2 did not receive msg-2"); + + targetCommand.destroy(); + await client.close(); +}); + +function logInTab(tab, message) { + return ContentTask.spawn(tab.linkedBrowser, message, function (_message) { + content.console.log(_message); + }); +} + +function hasMessage(messageResources, text) { + return messageResources.find( + resource => resource.message.arguments[0] === text + ); +} + +// All resource command callbacks share the same pattern here: they add all +// console message resources to a provided `messages` array. +function createMessageCallback(messages) { + const { CONSOLE_MESSAGE } = ResourceCommand.TYPES; + return async resources => { + for (const resource of resources) { + if (resource.resourceType === CONSOLE_MESSAGE) { + messages.push(resource); + } + } + }; +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js b/devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js new file mode 100644 index 0000000000..cc45e7bf7f --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that watching/unwatching multiple times works as expected + +add_task(async function () { + const TEST_URL = "data:text/html;charset=utf-8,<!DOCTYPE html>foo"; + const tab = await addTab(TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + let resources = []; + const onAvailable = _resources => { + resources.push(..._resources); + }; + + info("Watch for error messages resources"); + await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable, + }); + + ok( + resourceCommand.isResourceWatched(resourceCommand.TYPES.ERROR_MESSAGE), + "The error message resource is currently been watched." + ); + + is( + resources.length, + 0, + "no resources were received after the first watchResources call" + ); + + info("Trigger an error in the page"); + await ContentTask.spawn(tab.linkedBrowser, [], function frameScript() { + const document = content.document; + const scriptEl = document.createElement("script"); + scriptEl.textContent = `document.unknownFunction()`; + document.body.appendChild(scriptEl); + }); + + await waitFor(() => resources.length === 1); + const EXPECTED_ERROR_MESSAGE = + "TypeError: document.unknownFunction is not a function"; + is( + resources[0].pageError.errorMessage, + EXPECTED_ERROR_MESSAGE, + "The resource was received" + ); + + info("Unwatching resources…"); + resourceCommand.unwatchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable, + }); + + ok( + !resourceCommand.isResourceWatched(resourceCommand.TYPES.ERROR_MESSAGE), + "The error message resource is no longer been watched." + ); + // clearing resources + resources = []; + + info("…and watching again"); + await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable, + }); + + ok( + resourceCommand.isResourceWatched(resourceCommand.TYPES.ERROR_MESSAGE), + "The error message resource is been watched again." + ); + is( + resources.length, + 1, + "we retrieve the expected number of existing resources" + ); + is( + resources[0].pageError.errorMessage, + EXPECTED_ERROR_MESSAGE, + "The resource is the expected one" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_websocket.js b/devtools/shared/commands/resource/tests/browser_resources_websocket.js new file mode 100644 index 0000000000..601620bc59 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_websocket.js @@ -0,0 +1,245 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around WEBSOCKET. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const IS_NUMBER = "IS_NUMBER"; +const SHOULD_EXIST = "SHOULD_EXIST"; + +const targets = { + TOP_LEVEL_DOCUMENT: "top-level-document", + IN_PROCESS_IFRAME: "in-process-frame", + OUT_PROCESS_IFRAME: "out-process-frame", +}; + +add_task(async function () { + info("Testing the top-level document"); + await testWebsocketResources(targets.TOP_LEVEL_DOCUMENT); + info("Testing the in-process iframe"); + await testWebsocketResources(targets.IN_PROCESS_IFRAME); + info("Testing the out-of-process iframe"); + await testWebsocketResources(targets.OUT_PROCESS_IFRAME); +}); + +async function testWebsocketResources(target) { + const tab = await addTab(URL_ROOT_SSL + "websocket_frontend.html"); + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const availableResources = []; + function onResourceAvailable(resources) { + availableResources.push(...resources); + } + + await resourceCommand.watchResources([resourceCommand.TYPES.WEBSOCKET], { + onAvailable: onResourceAvailable, + }); + + info("Check available resources at initial"); + is( + availableResources.length, + 0, + "Length of existing resources is correct at initial" + ); + + info("Check resource of opening websocket"); + await executeFunctionInContext(tab, target, "openConnection"); + + await waitUntil(() => availableResources.length === 1); + + const httpChannelId = availableResources[0].httpChannelId; + + ok(httpChannelId, "httpChannelId is present in the resource"); + + assertResource(availableResources[0], { + wsMessageType: "webSocketOpened", + effectiveURI: + "wss://example.com/browser/devtools/shared/commands/resource/tests/websocket_backend", + extensions: "permessage-deflate", + protocols: "", + }); + + info("Check resource of sending/receiving the data via websocket"); + await executeFunctionInContext(tab, target, "sendData", "test"); + + await waitUntil(() => availableResources.length === 3); + + assertResource(availableResources[1], { + wsMessageType: "frameSent", + httpChannelId, + data: { + type: "sent", + payload: "test", + timeStamp: SHOULD_EXIST, + finBit: SHOULD_EXIST, + rsvBit1: SHOULD_EXIST, + rsvBit2: SHOULD_EXIST, + rsvBit3: SHOULD_EXIST, + opCode: SHOULD_EXIST, + mask: SHOULD_EXIST, + maskBit: SHOULD_EXIST, + }, + }); + assertResource(availableResources[2], { + wsMessageType: "frameReceived", + httpChannelId, + data: { + type: "received", + payload: "test", + timeStamp: SHOULD_EXIST, + finBit: SHOULD_EXIST, + rsvBit1: SHOULD_EXIST, + rsvBit2: SHOULD_EXIST, + rsvBit3: SHOULD_EXIST, + opCode: SHOULD_EXIST, + mask: SHOULD_EXIST, + maskBit: SHOULD_EXIST, + }, + }); + + info("Check resource of closing websocket"); + await executeFunctionInContext(tab, target, "closeConnection"); + + await waitUntil(() => availableResources.length === 6); + assertResource(availableResources[3], { + wsMessageType: "frameSent", + httpChannelId, + data: { + type: "sent", + payload: "", + timeStamp: SHOULD_EXIST, + finBit: SHOULD_EXIST, + rsvBit1: SHOULD_EXIST, + rsvBit2: SHOULD_EXIST, + rsvBit3: SHOULD_EXIST, + opCode: SHOULD_EXIST, + mask: SHOULD_EXIST, + maskBit: SHOULD_EXIST, + }, + }); + assertResource(availableResources[4], { + wsMessageType: "frameReceived", + httpChannelId, + data: { + type: "received", + payload: "", + timeStamp: SHOULD_EXIST, + finBit: SHOULD_EXIST, + rsvBit1: SHOULD_EXIST, + rsvBit2: SHOULD_EXIST, + rsvBit3: SHOULD_EXIST, + opCode: SHOULD_EXIST, + mask: SHOULD_EXIST, + maskBit: SHOULD_EXIST, + }, + }); + assertResource(availableResources[5], { + wsMessageType: "webSocketClosed", + httpChannelId, + code: IS_NUMBER, + reason: "", + wasClean: true, + }); + + info("Check existing resources"); + const existingResources = []; + + function onExsistingResourceAvailable(resources) { + existingResources.push(...resources); + } + + await resourceCommand.watchResources([resourceCommand.TYPES.WEBSOCKET], { + onAvailable: onExsistingResourceAvailable, + }); + + is( + availableResources.length, + existingResources.length, + "Length of existing resources is correct" + ); + + for (let i = 0; i < availableResources.length; i++) { + Assert.strictEqual( + availableResources[i], + existingResources[i], + `The ${i}th resource is correct` + ); + } + + await resourceCommand.unwatchResources([resourceCommand.TYPES.WEBSOCKET], { + onAvailable: onResourceAvailable, + }); + + await resourceCommand.unwatchResources([resourceCommand.TYPES.WEBSOCKET], { + onAvailable: onExsistingResourceAvailable, + }); + + targetCommand.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +} + +/** + * Execute global functions defined in the correct + * target (top-level-window or frames) contexts. + * + * @param {object} tab The current window tab + * @param {string} target A string identify if we want to test the top level document or iframes + * @param {string} funcName The name of the global function which needs to be called. + * @param {*} funcArgs The arguments to pass to the global function + */ +async function executeFunctionInContext(tab, target, funcName, ...funcArgs) { + let browsingContext = tab.linkedBrowser.browsingContext; + // If the target is an iframe get its window global + if (target !== targets.TOP_LEVEL_DOCUMENT) { + browsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [target], + async _target => { + const iframe = content.document.getElementById(_target); + return iframe.browsingContext; + } + ); + } + + return SpecialPowers.spawn( + browsingContext, + [funcName, funcArgs], + async (_funcName, _funcArgs) => { + await content.wrappedJSObject[_funcName](..._funcArgs); + } + ); +} + +function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.WEBSOCKET, + "Resource type is correct" + ); + + assertObject(resource, expected); +} + +function assertObject(object, expected) { + for (const field in expected) { + if (typeof expected[field] === "object") { + assertObject(object[field], expected[field]); + } else if (expected[field] === SHOULD_EXIST) { + Assert.notStrictEqual( + object[field], + undefined, + `The value of ${field} exists` + ); + } else if (expected[field] === IS_NUMBER) { + ok(!isNaN(object[field]), `The value of ${field} is number`); + } else { + is(object[field], expected[field], `The value of ${field} is correct`); + } + } +} diff --git a/devtools/shared/commands/resource/tests/doc_console.html b/devtools/shared/commands/resource/tests/doc_console.html new file mode 100644 index 0000000000..ee883cf47d --- /dev/null +++ b/devtools/shared/commands/resource/tests/doc_console.html @@ -0,0 +1,18 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test document for console</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +</head> + <body> + <p>Test document for console</p> + + <iframe src="data:text/html;charset=utf-8,foo<script>console.log('data url data log')</script>"></iframe> + <script> + "use strict"; + console.log("top-level document log"); + </script> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/doc_console_iframe.html b/devtools/shared/commands/resource/tests/doc_console_iframe.html new file mode 100644 index 0000000000..e088dff4e5 --- /dev/null +++ b/devtools/shared/commands/resource/tests/doc_console_iframe.html @@ -0,0 +1,16 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission iframe document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +</head> + <body> + <p>remote iframe</p> + <script> + "use strict"; + console.log(`${document.location.origin} iframe log`); + </script> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/early_console_document.html b/devtools/shared/commands/resource/tests/early_console_document.html new file mode 100644 index 0000000000..e4523dbdeb --- /dev/null +++ b/devtools/shared/commands/resource/tests/early_console_document.html @@ -0,0 +1,14 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <script> + "use strict"; + console.log("early-page-log"); + </script> +</head> +<body></body> +</html> diff --git a/devtools/shared/commands/resource/tests/empty.html b/devtools/shared/commands/resource/tests/empty.html new file mode 100644 index 0000000000..195b296bfe --- /dev/null +++ b/devtools/shared/commands/resource/tests/empty.html @@ -0,0 +1,11 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> + +<!DOCTYPE HTML> +<html> +<head> + <meta charset="UTF-8"> + <title>Empty page (No network requests)</title> +</head> +<body></body> +</html> diff --git a/devtools/shared/commands/resource/tests/fission_document.html b/devtools/shared/commands/resource/tests/fission_document.html new file mode 100644 index 0000000000..222f92d999 --- /dev/null +++ b/devtools/shared/commands/resource/tests/fission_document.html @@ -0,0 +1,23 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +</head> +<body> +<p>Test fission iframe</p> + +<script> + "use strict"; + const iframe = document.createElement("iframe"); + let iframeUrl = `https://example.org/browser/devtools/shared/commands/resource/tests/fission_iframe.html`; + if (document.location.search) { + iframeUrl += `?${new URLSearchParams(document.location.search)}`; + } + iframe.src = iframeUrl; + document.body.append(iframe); +</script> +</body> +</html> diff --git a/devtools/shared/commands/resource/tests/fission_document_workers.html b/devtools/shared/commands/resource/tests/fission_document_workers.html new file mode 100644 index 0000000000..bbbe3e8bf8 --- /dev/null +++ b/devtools/shared/commands/resource/tests/fission_document_workers.html @@ -0,0 +1,47 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <script> + "use strict"; + + const params = new URLSearchParams(document.location.search); + + // eslint-disable-next-line no-unused-vars + const worker = new Worker("https://example.com/browser/devtools/shared/commands/resource/tests/test_worker.js#simple-worker"); + + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker("https://example.com/browser/devtools/shared/commands/resource/tests/test_worker.js#shared-worker"); + + if (!params.has("noServiceWorker")) { + // Expose a reference to the registration so that tests can unregister it. + window.registrationPromise = navigator.serviceWorker.register("https://example.com/browser/devtools/shared/commands/resource/tests/test_service_worker.js#service-worker"); + } + + /* exported logMessageInWorker */ + function logMessageInWorker(message) { + worker.postMessage({ + type: "log-in-worker", + message, + }); + } + </script> +</head> +<body> +<p>Test fission iframe</p> + +<script> + "use strict"; + const iframe = document.createElement("iframe"); + let iframeUrl = `https://example.org/browser/devtools/shared/commands/resource/tests/fission_iframe_workers.html`; + if (document.location.search) { + iframeUrl += `?${new URLSearchParams(document.location.search)}`; + } + iframe.src = iframeUrl; + document.body.append(iframe); +</script> +</body> +</html> diff --git a/devtools/shared/commands/resource/tests/fission_iframe.html b/devtools/shared/commands/resource/tests/fission_iframe.html new file mode 100644 index 0000000000..f674321102 --- /dev/null +++ b/devtools/shared/commands/resource/tests/fission_iframe.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission iframe document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +</head> +<body> +<p>remote iframe</p> +</body> +</html> diff --git a/devtools/shared/commands/resource/tests/fission_iframe_workers.html b/devtools/shared/commands/resource/tests/fission_iframe_workers.html new file mode 100644 index 0000000000..deae49f833 --- /dev/null +++ b/devtools/shared/commands/resource/tests/fission_iframe_workers.html @@ -0,0 +1,29 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission iframe document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <script> + "use strict"; + const params = new URLSearchParams(document.location.search); + const hashSuffix = params.get("hashSuffix") || "in-iframe"; + // eslint-disable-next-line no-unused-vars + const worker = new Worker("test_worker.js#simple-worker-" + hashSuffix); + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker("test_worker.js#shared-worker-" + hashSuffix); + + /* exported logMessageInWorker */ + function logMessageInWorker(message) { + worker.postMessage({ + type: "log-in-worker", + message, + }); + } + </script> +</head> +<body> +<p>remote iframe</p> +</body> +</html> diff --git a/devtools/shared/commands/resource/tests/head.js b/devtools/shared/commands/resource/tests/head.js new file mode 100644 index 0000000000..5cee383070 --- /dev/null +++ b/devtools/shared/commands/resource/tests/head.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"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +const { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +async function _initResourceCommandFromCommands( + commands, + { listenForWorkers = false } = {} +) { + const targetCommand = commands.targetCommand; + if (listenForWorkers) { + targetCommand.listenForWorkers = true; + } + await targetCommand.startListening(); + + //Bug 1709065: Stop exporting resourceCommand and use commands.resourceCommand + //And rename all these methods + return { + client: commands.client, + commands, + resourceCommand: commands.resourceCommand, + targetCommand, + }; +} + +/** + * Instantiate a ResourceCommand for the given tab. + * + * @param {Tab} tab + * The browser frontend's tab to connect to. + * @param {Object} options + * @param {Boolean} options.listenForWorkers + * @return {Object} object + * @return {ResourceCommand} object.resourceCommand + * The underlying resource command interface. + * @return {Object} object.commands + * The commands object defined by modules from devtools/shared/commands. + * @return {DevToolsClient} object.client + * The underlying client instance. + * @return {TargetCommand} object.targetCommand + * The underlying target list instance. + */ +async function initResourceCommand(tab, options) { + const commands = await CommandsFactory.forTab(tab); + return _initResourceCommandFromCommands(commands, options); +} + +/** + * Instantiate a multi-process ResourceCommand, watching all type of targets. + * + * @return {Object} object + * @return {ResourceCommand} object.resourceCommand + * The underlying resource command interface. + * @return {Object} object.commands + * The commands object defined by modules from devtools/shared/commands. + * @return {DevToolsClient} object.client + * The underlying client instance. + * @return {DevToolsClient} object.targetCommand + * The underlying target list instance. + */ +async function initMultiProcessResourceCommand() { + const commands = await CommandsFactory.forMainProcess(); + return _initResourceCommandFromCommands(commands); +} + +// Copied from devtools/shared/webconsole/test/chrome/common.js +function checkObject(object, expected) { + if (object && object.getGrip) { + object = object.getGrip(); + } + + for (const name of Object.keys(expected)) { + const expectedValue = expected[name]; + const value = object[name]; + checkValue(name, value, expectedValue); + } +} + +function checkValue(name, value, expected) { + if (expected === null) { + is(value, null, `'${name}' is null`); + } else if (expected === undefined) { + is(value, expected, `'${name}' is undefined`); + } else if ( + typeof expected == "string" || + typeof expected == "number" || + typeof expected == "boolean" + ) { + is(value, expected, "property '" + name + "'"); + } else if (expected instanceof RegExp) { + ok(expected.test(value), name + ": " + expected + " matched " + value); + } else if (Array.isArray(expected)) { + info("checking array for property '" + name + "'"); + ok(Array.isArray(value), `property '${name}' is an array`); + + is(value.length, expected.length, "Array has expected length"); + if (value.length !== expected.length) { + is(JSON.stringify(value, null, 2), JSON.stringify(expected, null, 2)); + } else { + checkObject(value, expected); + } + } else if (typeof expected == "object") { + info("checking object for property '" + name + "'"); + checkObject(value, expected); + } +} + +async function triggerNetworkRequests(browser, commands) { + for (let i = 0; i < commands.length; i++) { + await SpecialPowers.spawn(browser, [commands[i]], async function (code) { + const script = content.document.createElement("script"); + script.append( + content.document.createTextNode( + `async function triggerRequest() {${code}}` + ) + ); + content.document.body.append(script); + await content.wrappedJSObject.triggerRequest(); + script.remove(); + }); + } +} + +/** + * Get the stylesheet text for a given stylesheet resource. + * + * @param {Object} styleSheetResource + * @returns Promise<String> + */ +async function getStyleSheetResourceText(styleSheetResource) { + const styleSheetsFront = await styleSheetResource.targetFront.getFront( + "stylesheets" + ); + const res = await styleSheetsFront.getText(styleSheetResource.resourceId); + return res.string(); +} diff --git a/devtools/shared/commands/resource/tests/network_document.html b/devtools/shared/commands/resource/tests/network_document.html new file mode 100644 index 0000000000..5c4744cb0c --- /dev/null +++ b/devtools/shared/commands/resource/tests/network_document.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> + <meta http-equiv="Pragma" content="no-cache" /> + <meta http-equiv="Expires" content="0" /> + <title>Test for network events</title> + </head> + <body> + <p>Test for network events</p> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/network_document_navigation.html b/devtools/shared/commands/resource/tests/network_document_navigation.html new file mode 100644 index 0000000000..c4ec651c05 --- /dev/null +++ b/devtools/shared/commands/resource/tests/network_document_navigation.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> + <meta http-equiv="Pragma" content="no-cache" /> + <meta http-equiv="Expires" content="0" /> + <title>Test for network events</title> + </head> + <body> + <p>Test for network events</p> + <script src="network_navigation.js" /> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/network_navigation.js b/devtools/shared/commands/resource/tests/network_navigation.js new file mode 100644 index 0000000000..6004b69d3c --- /dev/null +++ b/devtools/shared/commands/resource/tests/network_navigation.js @@ -0,0 +1 @@ +// empty script loaded by network_document_navigation.html diff --git a/devtools/shared/commands/resource/tests/service-worker-sources.js b/devtools/shared/commands/resource/tests/service-worker-sources.js new file mode 100644 index 0000000000..614644ee5d --- /dev/null +++ b/devtools/shared/commands/resource/tests/service-worker-sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function serviceWorkerSource() {} diff --git a/devtools/shared/commands/resource/tests/sources.html b/devtools/shared/commands/resource/tests/sources.html new file mode 100644 index 0000000000..9e1ad67d85 --- /dev/null +++ b/devtools/shared/commands/resource/tests/sources.html @@ -0,0 +1,53 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8"/> + </head> + <body> + <!-- introductionType=eventHandler --> + <div onclick="console.log('link')">link</div> + + <!-- introductionType=inlineScript mapped to scriptElement --> + <script type="text/javascript"> + "use strict"; + /* eslint-disable */ + function inlineSource() {} + + // introductionType=eval + // Assign it to a global in order to avoid it being GCed + eval("this.global = function evalFunction() {}"); + + // introductionType=Function + // Also assign to a global to avoid being GCed + this.global2 = new Function("return 42;"); + + // introductionType=injectedScript mapped to scriptElement + const script = document.createElement("script"); + script.textContent = "console.log('inline-script')"; + document.documentElement.appendChild(script); + + // introductionType=Worker, but ends up being null on SourceActor's form + // Assign the worker to a global variable in order to avoid + // having it be GCed. + this.worker = new Worker("worker-sources.js"); + + window.registrationPromise = navigator.serviceWorker.register("service-worker-sources.js"); + + // introductionType=domTimer + setTimeout(`console.log("timeout")`, 0); + + // introductionType=eventHandler + window.addEventListener("DOMContentLoaded", () => { + document.querySelector("div[onclick]").click(); + }); + </script> + <!-- introductionType=srcScript mapped to scriptElement --> + <script src="sources.js"></script> + <!-- introductionType=javascriptURL --> + <iframe src="javascript:666"></iframe> + <!-- srcdoc attribute on iframes --> + <iframe srcdoc="<script>console.log('srcdoc')</script> <script>console.log('srcdoc 2')</script>"></iframe> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/sources.js b/devtools/shared/commands/resource/tests/sources.js new file mode 100644 index 0000000000..7ae6c6272b --- /dev/null +++ b/devtools/shared/commands/resource/tests/sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function scriptSource() {} diff --git a/devtools/shared/commands/resource/tests/sse_backend.sjs b/devtools/shared/commands/resource/tests/sse_backend.sjs new file mode 100644 index 0000000000..777520577a --- /dev/null +++ b/devtools/shared/commands/resource/tests/sse_backend.sjs @@ -0,0 +1,8 @@ +"use strict"; + +function handleRequest(request, response) { + response.processAsync(); + response.setHeader("Content-Type", "text/event-stream"); + response.write("data: Why so serious?\n\n"); + response.finish(); +} diff --git a/devtools/shared/commands/resource/tests/sse_frontend.html b/devtools/shared/commands/resource/tests/sse_frontend.html new file mode 100644 index 0000000000..3bdddbc5bc --- /dev/null +++ b/devtools/shared/commands/resource/tests/sse_frontend.html @@ -0,0 +1,31 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8"/> + <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> + <meta http-equiv="Pragma" content="no-cache" /> + <meta http-equiv="Expires" content="0" /> + <title>SSE Inspection Test Page</title> + </head> + <body> + <h1>SSE Inspection Test Page</h1> + <script type="text/javascript"> + "use strict"; + + /* exported openConnection */ + function openConnection() { + return new Promise(resolve => { + const es = new EventSource("sse_backend.sjs"); + es.onmessage = function (e) { + es.close(); + resolve(); + }; + }); + } + </script> + <iframe id="in-process-frame" src="https://example.com/browser/devtools/shared/commands/resource/tests/sse_frontend_iframe.html"> </iframe> + <iframe id="out-process-frame" src="https://example.org/browser/devtools/shared/commands/resource/tests/sse_frontend_iframe.html"></iframe> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/sse_frontend_iframe.html b/devtools/shared/commands/resource/tests/sse_frontend_iframe.html new file mode 100644 index 0000000000..477dca013d --- /dev/null +++ b/devtools/shared/commands/resource/tests/sse_frontend_iframe.html @@ -0,0 +1,29 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8"/> + <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> + <meta http-equiv="Pragma" content="no-cache" /> + <meta http-equiv="Expires" content="0" /> + <title>SSE Inspection Test Page in iframe</title> + </head> + <body> + <h1>SSE Inspection Test Page in Iframe</h1> + <script type="text/javascript"> + "use strict"; + + /* exported openConnection */ + function openConnection() { + return new Promise(resolve => { + const es = new EventSource("sse_backend.sjs"); + es.onmessage = function (e) { + es.close(); + resolve(); + }; + }); + } + </script> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/style_document.css b/devtools/shared/commands/resource/tests/style_document.css new file mode 100644 index 0000000000..aa54533924 --- /dev/null +++ b/devtools/shared/commands/resource/tests/style_document.css @@ -0,0 +1 @@ +body { margin: 1px; } diff --git a/devtools/shared/commands/resource/tests/style_document.html b/devtools/shared/commands/resource/tests/style_document.html new file mode 100644 index 0000000000..deaf6c4248 --- /dev/null +++ b/devtools/shared/commands/resource/tests/style_document.html @@ -0,0 +1,22 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf8"> + <title>Test style document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <style> + body { color: lime; } + </style> + <link href="style_document.css" rel="stylesheet"> + <script> + "use strict"; + const s = new CSSStyleSheet(); + s.replaceSync("body { background-color: blue }"); + document.adoptedStyleSheets.push(s); + </script> + </head> + <body> + <iframe src="https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.html"></iframe> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/style_iframe.css b/devtools/shared/commands/resource/tests/style_iframe.css new file mode 100644 index 0000000000..30e7ae802b --- /dev/null +++ b/devtools/shared/commands/resource/tests/style_iframe.css @@ -0,0 +1 @@ +body { padding: 1px; } diff --git a/devtools/shared/commands/resource/tests/style_iframe.html b/devtools/shared/commands/resource/tests/style_iframe.html new file mode 100644 index 0000000000..11ad9f785b --- /dev/null +++ b/devtools/shared/commands/resource/tests/style_iframe.html @@ -0,0 +1,15 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf8"> + <title>Test style iframe document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <style> + body { background-color: pink; } + </style> + <link href="style_iframe.css" rel="stylesheet" type="text/css"/> + </head> + <body> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/stylesheets-nested-iframes.html b/devtools/shared/commands/resource/tests/stylesheets-nested-iframes.html new file mode 100644 index 0000000000..eb6c371867 --- /dev/null +++ b/devtools/shared/commands/resource/tests/stylesheets-nested-iframes.html @@ -0,0 +1,27 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>StyleSheetsActor iframe test</title> + <style> + p { + padding: 1em; + } + </style> +</head> +<body> + <p>A test page with nested iframes</p> + <iframe></iframe> + <script type="application/javascript"> + "use strict"; + + const iframe = document.querySelector("iframe"); + let i = parseInt(location.href.split("?")[1], 10) || 1; + + // The frame can't have the same src URL as any of its ancestors. + // This will not infinitely recurse because a frame won't get a content + // document once it's nested deeply enough. + iframe.src = location.href.split("?")[0] + "?" + (++i); + </script> +</body> +</html> diff --git a/devtools/shared/commands/resource/tests/test_image.png b/devtools/shared/commands/resource/tests/test_image.png Binary files differnew file mode 100644 index 0000000000..769c636340 --- /dev/null +++ b/devtools/shared/commands/resource/tests/test_image.png diff --git a/devtools/shared/commands/resource/tests/test_service_worker.js b/devtools/shared/commands/resource/tests/test_service_worker.js new file mode 100644 index 0000000000..aabc3fda0f --- /dev/null +++ b/devtools/shared/commands/resource/tests/test_service_worker.js @@ -0,0 +1,11 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// We don't need any computation in the worker, +// but at least register a fetch listener so that +// we force instantiating the SW when loading the page. +self.onfetch = function (event) { + // do nothing. +}; diff --git a/devtools/shared/commands/resource/tests/test_worker.js b/devtools/shared/commands/resource/tests/test_worker.js new file mode 100644 index 0000000000..60ccc6d52b --- /dev/null +++ b/devtools/shared/commands/resource/tests/test_worker.js @@ -0,0 +1,15 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +console.log("[WORKER] started", globalThis.location.toString(), globalThis); + +globalThis.onmessage = function (e) { + const { type, message } = e.data; + + if (type === "log-in-worker") { + // Printing `e` so we can check that we have an object and not a stringified version + console.log("[WORKER]", message, e); + } +}; diff --git a/devtools/shared/commands/resource/tests/websocket_backend_wsh.py b/devtools/shared/commands/resource/tests/websocket_backend_wsh.py new file mode 100644 index 0000000000..170f15fe6c --- /dev/null +++ b/devtools/shared/commands/resource/tests/websocket_backend_wsh.py @@ -0,0 +1,20 @@ +from mod_pywebsocket import msgutil + + +def web_socket_do_extra_handshake(request): + pass + + +def web_socket_transfer_data(request): + while not request.client_terminated: + resp = msgutil.receive_message(request) + msgutil.send_message(request, resp) + + +def web_socket_passive_closing_handshake(request): + # If we use `pass` here, the `payload` of `frameReceived` which will be happened + # of communication of closing will be `\u0003è`. In order to make the `payload` + # to be empty string, return code and reason explicitly. + code = None + reason = None + return code, reason diff --git a/devtools/shared/commands/resource/tests/websocket_frontend.html b/devtools/shared/commands/resource/tests/websocket_frontend.html new file mode 100644 index 0000000000..7efe11f9eb --- /dev/null +++ b/devtools/shared/commands/resource/tests/websocket_frontend.html @@ -0,0 +1,45 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8" /> + <title>Websocket Inspection Test Page</title> + </head> + <body> + <h1>Websocket Inspection Test Page</h1> + <script type="text/javascript"> + /* exported openConnection, closeConnection, sendData */ + "use strict"; + + let webSocket; + function openConnection() { + return new Promise(resolve => { + webSocket = new WebSocket( + "wss://example.com/browser/devtools/shared/commands/resource/tests/websocket_backend" + ); + webSocket.onopen = () => { + resolve(); + }; + }); + } + + function closeConnection() { + return new Promise(resolve => { + webSocket.onclose = () => { + resolve(); + }; + webSocket.close(); + }) + } + + function sendData(payload) { + webSocket.send(payload); + } + </script> + <iframe id="in-process-frame" + src="https://example.com/browser/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html"></iframe> + <iframe id="out-process-frame" + src="https://example.org/browser/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html"></iframe> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html b/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html new file mode 100644 index 0000000000..e18576f911 --- /dev/null +++ b/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html @@ -0,0 +1,41 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8"/> + <title>Websocket Inspection Test Page</title> + </head> + <body> + <h1>Websocket Inspection Test Page</h1> + <script type="text/javascript"> + /* exported openConnection, closeConnection, sendData */ + "use strict"; + + let webSocket; + function openConnection() { + return new Promise(resolve => { + webSocket = new WebSocket( + "wss://example.com/browser/devtools/shared/commands/resource/tests/websocket_backend" + ); + webSocket.onopen = () => { + resolve(); + }; + }); + } + + function closeConnection() { + return new Promise(resolve => { + webSocket.onclose = () => { + resolve(); + }; + webSocket.close(); + }) + } + + function sendData(payload) { + webSocket.send(payload); + } + </script> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/worker-sources.js b/devtools/shared/commands/resource/tests/worker-sources.js new file mode 100644 index 0000000000..dcf2ed8031 --- /dev/null +++ b/devtools/shared/commands/resource/tests/worker-sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function workerSource() {} diff --git a/devtools/shared/commands/resource/transformers/console-messages.js b/devtools/shared/commands/resource/transformers/console-messages.js new file mode 100644 index 0000000000..9c8ca51f04 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/console-messages.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// eslint-disable-next-line mozilla/reject-some-requires +loader.lazyRequireGetter( + this, + "getAdHocFrontOrPrimitiveGrip", + "resource://devtools/client/fronts/object.js", + true +); + +module.exports = function ({ resource, targetFront }) { + if (Array.isArray(resource.message.arguments)) { + // We might need to create fronts for each of the message arguments. + resource.message.arguments = resource.message.arguments.map(arg => + getAdHocFrontOrPrimitiveGrip(arg, targetFront) + ); + } + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/error-messages.js b/devtools/shared/commands/resource/transformers/error-messages.js new file mode 100644 index 0000000000..2b71f5b7ca --- /dev/null +++ b/devtools/shared/commands/resource/transformers/error-messages.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// eslint-disable-next-line mozilla/reject-some-requires +loader.lazyRequireGetter( + this, + "getAdHocFrontOrPrimitiveGrip", + "resource://devtools/client/fronts/object.js", + true +); + +module.exports = function ({ resource, targetFront }) { + if (resource?.pageError?.errorMessage) { + resource.pageError.errorMessage = getAdHocFrontOrPrimitiveGrip( + resource.pageError.errorMessage, + targetFront + ); + } + + if (resource?.pageError?.exception) { + resource.pageError.exception = getAdHocFrontOrPrimitiveGrip( + resource.pageError.exception, + targetFront + ); + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/moz.build b/devtools/shared/commands/resource/transformers/moz.build new file mode 100644 index 0000000000..5b0b94853a --- /dev/null +++ b/devtools/shared/commands/resource/transformers/moz.build @@ -0,0 +1,16 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "console-messages.js", + "error-messages.js", + "network-events.js", + "storage-cache.js", + "storage-cookie.js", + "storage-extension.js", + "storage-indexed-db.js", + "storage-local-storage.js", + "storage-session-storage.js", + "thread-states.js", +) diff --git a/devtools/shared/commands/resource/transformers/network-events.js b/devtools/shared/commands/resource/transformers/network-events.js new file mode 100644 index 0000000000..d7f757d706 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/network-events.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + getUrlDetails, + // eslint-disable-next-line mozilla/reject-some-requires +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); + +module.exports = function ({ resource }) { + resource.urlDetails = getUrlDetails(resource.url); + resource.startedMs = Date.parse(resource.startedDateTime); + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-cache.js b/devtools/shared/commands/resource/transformers/storage-cache.js new file mode 100644 index 0000000000..245d892041 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-cache.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { CACHE_STORAGE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + // instantiate front for local storage + resource = types.getType("Cache").read(resource, targetFront); + resource.resourceType = CACHE_STORAGE; + resource.resourceKey = "Cache"; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-cookie.js b/devtools/shared/commands/resource/transformers/storage-cookie.js new file mode 100644 index 0000000000..23e221672b --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-cookie.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { COOKIE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + const { innerWindowId } = resource; + + // it's safe to instantiate the front now, so we do it. + resource = types.getType("cookies").read(resource, targetFront); + resource.resourceType = COOKIE; + resource.resourceId = `${COOKIE}-${targetFront.browsingContextID}`; + resource.resourceKey = "cookies"; + resource.innerWindowId = innerWindowId; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-extension.js b/devtools/shared/commands/resource/transformers/storage-extension.js new file mode 100644 index 0000000000..3e40bdd6d0 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-extension.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { EXTENSION_STORAGE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + const { innerWindowId } = resource; + + // it's safe to instantiate the front now, so we do it. + resource = types.getType("extensionStorage").read(resource, targetFront); + resource.resourceType = EXTENSION_STORAGE; + resource.resourceId = `${EXTENSION_STORAGE}-${targetFront.browsingContextID}`; + resource.resourceKey = "extensionStorage"; + resource.innerWindowId = innerWindowId; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-indexed-db.js b/devtools/shared/commands/resource/transformers/storage-indexed-db.js new file mode 100644 index 0000000000..8021719070 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-indexed-db.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { INDEXED_DB }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + const { innerWindowId } = resource; + + // it's safe to instantiate the front now, so we do it. + resource = types.getType("indexedDB").read(resource, targetFront); + resource.resourceType = INDEXED_DB; + resource.resourceId = `${INDEXED_DB}-${targetFront.browsingContextID}`; + resource.resourceKey = "indexedDB"; + resource.innerWindowId = innerWindowId; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-local-storage.js b/devtools/shared/commands/resource/transformers/storage-local-storage.js new file mode 100644 index 0000000000..13488723f3 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-local-storage.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { LOCAL_STORAGE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + // instantiate front for local storage + resource = types.getType("localStorage").read(resource, targetFront); + resource.resourceType = LOCAL_STORAGE; + resource.resourceKey = "localStorage"; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-session-storage.js b/devtools/shared/commands/resource/transformers/storage-session-storage.js new file mode 100644 index 0000000000..ab9f1361c8 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-session-storage.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { SESSION_STORAGE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + // instantiate front for session storage + resource = types.getType("sessionStorage").read(resource, targetFront); + resource.resourceType = SESSION_STORAGE; + resource.resourceKey = "sessionStorage"; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/thread-states.js b/devtools/shared/commands/resource/transformers/thread-states.js new file mode 100644 index 0000000000..1564585b36 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/thread-states.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + // only "paused" have a frame attribute, and legacy listeners are already passing a FrameFront + if (resource.frame && !(resource.frame instanceof Front)) { + // Use ThreadFront as parent as debugger's commands.js expects FrameFront to be children + // of the ThreadFront. + resource.frame = types + .getType("frame") + .read(resource.frame, targetFront.threadFront); + } + + // If we are using server side request (i.e. watcherFront is defined) + // Fake paused and resumed events as the thread front depends on them. + // We can't emit "EventEmitter" events, as ThreadFront uses `Front.before` + // to listen for paused and resumed. ("before" is part of protocol.js Front and not part of EventEmitter) + if (watcherFront) { + if (resource.state == "paused") { + targetFront.threadFront._beforePaused(resource); + } else if (resource.state == "resumed") { + targetFront.threadFront._beforeResumed(resource); + } + } + + return resource; +}; diff --git a/devtools/shared/commands/root-resource/moz.build b/devtools/shared/commands/root-resource/moz.build new file mode 100644 index 0000000000..2bf7204d1f --- /dev/null +++ b/devtools/shared/commands/root-resource/moz.build @@ -0,0 +1,7 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "root-resource-command.js", +) diff --git a/devtools/shared/commands/root-resource/root-resource-command.js b/devtools/shared/commands/root-resource/root-resource-command.js new file mode 100644 index 0000000000..1071d1bcb1 --- /dev/null +++ b/devtools/shared/commands/root-resource/root-resource-command.js @@ -0,0 +1,348 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { throttle } = require("resource://devtools/shared/throttle.js"); + +class RootResourceCommand { + /** + * This class helps retrieving existing and listening to "root" resources. + * + * This is a fork of ResourceCommand, but specific to context-less + * resources which can be listened to right away when connecting to the RDP server. + * + * The main difference in term of implementation is that: + * - we receive a root front as constructor argument (instead of `commands` object) + * - we only listen for RDP events on the Root actor (instead of watcher and target actors) + * - there is no legacy listener support + * - there is no resource transformers + * - there is a lot of logic around targets that is removed here. + * + * See ResourceCommand for comments and jsdoc. + * + * TODO Bug 1758530 - Investigate sharing code with ResourceCommand instead of forking. + * + * @param object commands + * The commands object with all interfaces defined from devtools/shared/commands/ + * @param object rootFront + * Front for the Root actor. + */ + constructor({ commands, rootFront }) { + this.rootFront = rootFront ? rootFront : commands.client.mainRoot; + + this._onResourceAvailable = this._onResourceAvailable.bind(this); + this._onResourceDestroyed = this._onResourceDestroyed.bind(this); + + this._watchers = []; + + this._pendingWatchers = new Set(); + + this._cache = []; + this._listenedResources = new Set(); + + this._processingExistingResources = new Set(); + + this._notifyWatchers = this._notifyWatchers.bind(this); + this._throttledNotifyWatchers = throttle(this._notifyWatchers, 100); + } + + getAllResources(resourceType) { + return this._cache.filter(r => r.resourceType === resourceType); + } + + getResourceById(resourceType, resourceId) { + return this._cache.find( + r => r.resourceType === resourceType && r.resourceId === resourceId + ); + } + + async watchResources(resources, options) { + const { + onAvailable, + onUpdated, + onDestroyed, + ignoreExistingResources = false, + } = options; + + if (typeof onAvailable !== "function") { + throw new Error( + "RootResourceCommand.watchResources expects an onAvailable function as argument" + ); + } + + for (const type of resources) { + if (!this._isValidResourceType(type)) { + throw new Error( + `RootResourceCommand.watchResources invoked with an unknown type: "${type}"` + ); + } + } + + const pendingWatcher = { + resources, + onAvailable, + }; + this._pendingWatchers.add(pendingWatcher); + + if (!this._listenerRegistered) { + this._listenerRegistered = true; + this.rootFront.on("resource-available-form", this._onResourceAvailable); + this.rootFront.on("resource-destroyed-form", this._onResourceDestroyed); + } + + const promises = []; + for (const resource of resources) { + promises.push(this._startListening(resource)); + } + await Promise.all(promises); + + this._notifyWatchers(); + + this._pendingWatchers.delete(pendingWatcher); + + const watchedResources = pendingWatcher.resources; + + if (!watchedResources.length) { + return; + } + + this._watchers.push({ + resources: watchedResources, + onAvailable, + onUpdated, + onDestroyed, + pendingEvents: [], + }); + + if (!ignoreExistingResources) { + await this._forwardExistingResources(watchedResources, onAvailable); + } + } + + unwatchResources(resources, options) { + const { onAvailable } = options; + + if (typeof onAvailable !== "function") { + throw new Error( + "RootResourceCommand.unwatchResources expects an onAvailable function as argument" + ); + } + + for (const type of resources) { + if (!this._isValidResourceType(type)) { + throw new Error( + `RootResourceCommand.unwatchResources invoked with an unknown type: "${type}"` + ); + } + } + + const allWatchers = [...this._watchers, ...this._pendingWatchers]; + for (const watcherEntry of allWatchers) { + if (watcherEntry.onAvailable == onAvailable) { + watcherEntry.resources = watcherEntry.resources.filter(resourceType => { + return !resources.includes(resourceType); + }); + } + } + this._watchers = this._watchers.filter(entry => { + return !!entry.resources.length; + }); + + for (const resource of resources) { + const isResourceWatched = allWatchers.some(watcherEntry => + watcherEntry.resources.includes(resource) + ); + + if (!isResourceWatched && this._listenedResources.has(resource)) { + this._stopListening(resource); + } + } + } + + clearResources(resourceTypes) { + if (!Array.isArray(resourceTypes)) { + throw new Error("clearResources expects an array of resource types"); + } + // Clear the cached resources of the type. + this._cache = this._cache.filter( + cachedResource => !resourceTypes.includes(cachedResource.resourceType) + ); + + if (resourceTypes.length) { + this.rootFront.clearResources(resourceTypes); + } + } + + async waitForNextResource( + resourceType, + { ignoreExistingResources = false, predicate } = {} + ) { + predicate = predicate || (resource => !!resource); + + let resolve; + const promise = new Promise(r => (resolve = r)); + const onAvailable = async resources => { + const matchingResource = resources.find(resource => predicate(resource)); + if (matchingResource) { + this.unwatchResources([resourceType], { onAvailable }); + resolve(matchingResource); + } + }; + + await this.watchResources([resourceType], { + ignoreExistingResources, + onAvailable, + }); + return { onResource: promise }; + } + + async _onResourceAvailable(resources) { + for (const resource of resources) { + const { resourceType } = resource; + + resource.isAlreadyExistingResource = + this._processingExistingResources.has(resourceType); + + this._queueResourceEvent("available", resourceType, resource); + + this._cache.push(resource); + } + + this._throttledNotifyWatchers(); + } + + async _onResourceDestroyed(resources) { + for (const resource of resources) { + const { resourceType, resourceId } = resource; + + let index = -1; + if (resourceId) { + index = this._cache.findIndex( + cachedResource => + cachedResource.resourceType == resourceType && + cachedResource.resourceId == resourceId + ); + } else { + index = this._cache.indexOf(resource); + } + if (index >= 0) { + this._cache.splice(index, 1); + } else { + console.warn( + `Resource ${resourceId || ""} of ${resourceType} was not found.` + ); + } + + this._queueResourceEvent("destroyed", resourceType, resource); + } + this._throttledNotifyWatchers(); + } + + _queueResourceEvent(callbackType, resourceType, update) { + for (const { resources, pendingEvents } of this._watchers) { + if (!resources.includes(resourceType)) { + continue; + } + if (pendingEvents.length) { + const lastEvent = pendingEvents[pendingEvents.length - 1]; + if (lastEvent.callbackType == callbackType) { + lastEvent.updates.push(update); + continue; + } + } + pendingEvents.push({ + callbackType, + updates: [update], + }); + } + } + + _notifyWatchers() { + for (const watcherEntry of this._watchers) { + const { onAvailable, onDestroyed, pendingEvents } = watcherEntry; + watcherEntry.pendingEvents = []; + + for (const { callbackType, updates } of pendingEvents) { + try { + if (callbackType == "available") { + onAvailable(updates, { areExistingResources: false }); + } else if (callbackType == "destroyed" && onDestroyed) { + onDestroyed(updates); + } + } catch (e) { + console.error( + "Exception while calling a RootResourceCommand", + callbackType, + "callback", + ":", + e + ); + } + } + } + } + + _isValidResourceType(type) { + return this.ALL_TYPES.includes(type); + } + + async _startListening(resourceType) { + if (this._listenedResources.has(resourceType)) { + return; + } + this._listenedResources.add(resourceType); + + this._processingExistingResources.add(resourceType); + + // For now, if the server doesn't support the resource type + // act as if we were listening, but do nothing. + // Calling watchResources/unwatchResources will work fine, + // but no resource will be notified. + if (this.rootFront.traits.resources?.[resourceType]) { + await this.rootFront.watchResources([resourceType]); + } else { + console.warn( + `Ignored watchRequest, resourceType "${resourceType}" not found in rootFront.traits.resources` + ); + } + this._processingExistingResources.delete(resourceType); + } + + async _forwardExistingResources(resourceTypes, onAvailable) { + const existingResources = this._cache.filter(resource => + resourceTypes.includes(resource.resourceType) + ); + if (existingResources.length) { + await onAvailable(existingResources, { areExistingResources: true }); + } + } + + _stopListening(resourceType) { + if (!this._listenedResources.has(resourceType)) { + throw new Error( + `Stopped listening for resource '${resourceType}' that isn't being listened to` + ); + } + this._listenedResources.delete(resourceType); + + this._cache = this._cache.filter( + cachedResource => cachedResource.resourceType !== resourceType + ); + + if ( + !this.rootFront.isDestroyed() && + this.rootFront.traits.resources?.[resourceType] + ) { + this.rootFront.unwatchResources([resourceType]); + } + } +} + +RootResourceCommand.TYPES = RootResourceCommand.prototype.TYPES = { + EXTENSIONS_BGSCRIPT_STATUS: "extensions-backgroundscript-status", +}; +RootResourceCommand.ALL_TYPES = RootResourceCommand.prototype.ALL_TYPES = + Object.values(RootResourceCommand.TYPES); +module.exports = RootResourceCommand; diff --git a/devtools/shared/commands/script/moz.build b/devtools/shared/commands/script/moz.build new file mode 100644 index 0000000000..70570b2599 --- /dev/null +++ b/devtools/shared/commands/script/moz.build @@ -0,0 +1,10 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "script-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/script/script-command.js b/devtools/shared/commands/script/script-command.js new file mode 100644 index 0000000000..cf5a7e263e --- /dev/null +++ b/devtools/shared/commands/script/script-command.js @@ -0,0 +1,157 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + getAdHocFrontOrPrimitiveGrip, + // eslint-disable-next-line mozilla/reject-some-requires +} = require("resource://devtools/client/fronts/object.js"); + +class ScriptCommand { + constructor({ commands }) { + this._commands = commands; + } + + /** + * Execute a JavaScript expression. + * + * @param {String} expression: The code you want to evaluate. + * @param {Object} options: Options for evaluation: + * @param {Object} options.frameActor: a FrameActor ID. The actor holds a reference to + * a Debugger.Frame. This option allows you to evaluate the string in the frame + * of the given FrameActor. + * @param {String} options.url: the url to evaluate the script as. Defaults to "debugger eval code". + * @param {TargetFront} options.selectedTargetFront: When passed, the expression will be + * evaluated in the context of the target (as opposed to the default, top-level one). + * @param {String} options.selectedNodeActor: A NodeActor ID that may be used by helper + * functions that can reference the currently selected node in the Inspector, like $0. + * @param {String} options.selectedObjectActor: the actorID of a given objectActor. + * This is used by context menu entries to get a reference to an object, in order + * to perform some operation on it (copy it, store it as a global variable, …). + * @param {Number} options.innerWindowID: An optional window id to be used for the evaluation, + * instead of the regular webConsoleActor.evalWindow. + * This is used by functions that may want to evaluate in a different window (for + * example a non-remote iframe), like getting the elements of a given document. + * @param {object} options.mapped: An optional object indicating if the original expression + * entered by the users have been modified + * @param {boolean} options.mapped.await: true if the expression was a top-level await + * expression that was wrapped in an async-iife + * @param {boolean} options.disableBreaks: Set to true to avoid triggering any + * type of breakpoint when evaluating the source. Also, the evaluated source won't be + * visible in the debugger UI. + * @param {boolean} options.preferConsoleCommandsOverLocalSymbols: Set to true to force + * overriding local symbols defined by the page with same-name console commands. + * + * @return {Promise}: A promise that resolves with the response. + */ + async execute(expression, options = {}) { + const { + selectedObjectActor, + selectedNodeActor, + frameActor, + selectedTargetFront, + } = options; + + // Retrieve the right WebConsole front that relates either to (by order of priority): + // - the currently selected target in the context selector + // (selectedTargetFront argument), + // - the object picked in the console (when using store as global) (selectedObjectActor), + // - the currently selected Node in the inspector (selectedNodeActor), + // - the currently selected frame in the debugger (when paused) (frameActor), + // - the currently selected target in the iframe dropdown + // (selectedTargetFront from the TargetCommand) + let targetFront = this._commands.targetCommand.selectedTargetFront; + + const selectedActor = + selectedObjectActor || selectedNodeActor || frameActor; + + if (selectedTargetFront) { + targetFront = selectedTargetFront; + } else if (selectedActor) { + const selectedFront = this._commands.client.getFrontByID(selectedActor); + if (selectedFront) { + targetFront = selectedFront.targetFront; + } + } + + const consoleFront = await targetFront.getFront("console"); + + // We call `evaluateJSAsync` RDP request, which immediately returns a simple `resultID`, + // for which we later receive a related `evaluationResult` RDP event, with the same resultID. + // The evaluation result will be contained in this RDP event. + let resultID; + const response = await new Promise(resolve => { + const offEvaluationResult = consoleFront.on( + "evaluationResult", + async packet => { + // In some cases, the evaluationResult event can be received before the call to + // evaluationJSAsync completes. So make sure to wait for the corresponding promise + // before handling the evaluationResult event. + await onEvaluateJSAsync; + + if (packet.resultID === resultID) { + resolve(packet); + offEvaluationResult(); + } + } + ); + + const onEvaluateJSAsync = consoleFront + .evaluateJSAsync({ + text: expression, + eager: options.eager, + frameActor, + innerWindowID: options.innerWindowID, + mapped: options.mapped, + selectedNodeActor, + selectedObjectActor, + url: options.url, + disableBreaks: options.disableBreaks, + preferConsoleCommandsOverLocalSymbols: + options.preferConsoleCommandsOverLocalSymbols, + }) + .then(packet => { + resultID = packet.resultID; + }); + }); + + // `response` is the packet sent via `evaluationResult` RDP event. + if (response.error) { + throw response; + } + + if (response.result) { + response.result = getAdHocFrontOrPrimitiveGrip( + response.result, + consoleFront + ); + } + + if (response.helperResult?.object) { + response.helperResult.object = getAdHocFrontOrPrimitiveGrip( + response.helperResult.object, + consoleFront + ); + } + + if (response.exception) { + response.exception = getAdHocFrontOrPrimitiveGrip( + response.exception, + consoleFront + ); + } + + if (response.exceptionMessage) { + response.exceptionMessage = getAdHocFrontOrPrimitiveGrip( + response.exceptionMessage, + consoleFront + ); + } + + return response; + } +} + +module.exports = ScriptCommand; diff --git a/devtools/shared/commands/script/tests/browser.toml b/devtools/shared/commands/script/tests/browser.toml new file mode 100644 index 0000000000..21b59c0ea3 --- /dev/null +++ b/devtools/shared/commands/script/tests/browser.toml @@ -0,0 +1,15 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "head.js", +] + +["browser_script_command_execute_basic.js"] + +["browser_script_command_execute_document__proto__.js"] + +["browser_script_command_execute_last_result.js"] + +["browser_script_command_execute_throw.js"] diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js b/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js new file mode 100644 index 0000000000..e63f55a338 --- /dev/null +++ b/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js @@ -0,0 +1,1050 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing basic expression evaluation +const { + MAX_AUTOCOMPLETE_ATTEMPTS, + MAX_AUTOCOMPLETIONS, +} = require("resource://devtools/shared/webconsole/js-property-provider.js"); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +add_task(async () => { + const tab = await addTab(`data:text/html;charset=utf-8, + <!DOCTYPE html> + <html dir="ltr" class="class1"> + <head><title>Testcase</title></head> + <script> + window.foobarObject = Object.create( + null, + Object.getOwnPropertyDescriptors({ + foo: 1, + foobar: 2, + foobaz: 3, + omg: 4, + omgfoo: 5, + strfoo: "foobarz", + omgstr: "foobarz" + "abb".repeat(${DevToolsServer.LONG_STRING_LENGTH} * 2), + }) + ); + + window.largeObject1 = Object.create(null); + for (let i = 0; i < ${MAX_AUTOCOMPLETE_ATTEMPTS} + 1; i++) { + window.largeObject1["a" + i] = i; + } + + window.largeObject2 = Object.create(null); + for (let i = 0; i < ${MAX_AUTOCOMPLETIONS} * 2; i++) { + window.largeObject2["a" + i] = i; + } + + var originalExec = RegExp.prototype.exec; + + var promptIterable = { [Symbol.iterator]() { return { next: prompt } } }; + + function aliasedTest() { + const aliased = "ALIASED"; + return [0].map(() => aliased)[0]; + } + + var testMap = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]); + var testSet = new Set([1, 2, 3, 4, 5]); + var testProxy = new Proxy({}, { getPrototypeOf: prompt }); + var testArray = [1,2,3]; + var testInt8Array = new Int8Array([1, 2, 3]); + var testArrayBuffer = testInt8Array.buffer; + var testDataView = new DataView(testArrayBuffer, 2); + + var testCanvasContext = document.createElement("canvas").getContext("2d"); + + var objWithNativeGetter = {}; + Object.defineProperty(objWithNativeGetter, "print", { get: print }); + Object.defineProperty(objWithNativeGetter, "Element", { get: Element }); + Object.defineProperty(objWithNativeGetter, "setAttribute", { get: Element.prototype.setAttribute }); + Object.defineProperty(objWithNativeGetter, "setClassName", { get: Object.getOwnPropertyDescriptor(Element.prototype, "className").set }); + Object.defineProperty(objWithNativeGetter, "requestPermission", { get: Notification.requestPermission }); + + async function testAsync() { return 10; } + async function testAsyncAwait() { await 1; return 10; } + async function * testAsyncGen() { return 10; } + async function * testAsyncGenAwait() { await 1; return 10; } + + function testFunc() {} + + var testLocale = new Intl.Locale("de-latn-de-u-ca-gregory-co-phonebk-hc-h23-kf-true-kn-false-nu-latn"); + </script> + <body id="body1" class="class2"><h1>Body text</h1></body> + </html>`); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + await doSimpleEval(commands); + await doWindowEval(commands); + await doEvalWithException(commands); + await doEvalWithHelper(commands); + await doEvalString(commands); + await doEvalLongString(commands); + await doEvalWithBinding(commands); + await forceLexicalInit(commands); + await doSimpleEagerEval(commands); + await doEagerEvalWithSideEffect(commands); + await doEagerEvalWithSideEffectIterator(commands); + await doEagerEvalWithSideEffectMonkeyPatched(commands); + await doEagerEvalESGetters(commands); + await doEagerEvalDOMGetters(commands); + await doEagerEvalOtherNativeGetters(commands); + await doEagerEvalAsyncFunctions(commands); + + await commands.destroy(); +}); + +async function doSimpleEval(commands) { + info("test eval '2+2'"); + const response = await commands.scriptCommand.execute("2+2"); + checkObject(response, { + input: "2+2", + result: 4, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); +} + +async function doWindowEval(commands) { + info("test eval 'document'"); + const response = await commands.scriptCommand.execute("document"); + checkObject(response, { + input: "document", + result: { + type: "object", + class: "HTMLDocument", + actor: /[a-z]/, + }, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); +} + +async function doEvalWithException(commands) { + info("test eval with exception"); + const response = await commands.scriptCommand.execute( + "window.doTheImpossible()" + ); + checkObject(response, { + input: "window.doTheImpossible()", + result: { + type: "undefined", + }, + exceptionMessage: /doTheImpossible/, + }); + + ok(response.exception, "js eval exception"); + ok(!response.helperResult, "no helper result"); +} + +async function doEvalWithHelper(commands) { + info("test eval with helper"); + const response = await commands.scriptCommand.execute("clear()"); + checkObject(response, { + input: "clear()", + result: { + type: "undefined", + }, + helperResult: { type: "clearOutput" }, + }); + + ok(!response.exception, "no eval exception"); +} + +async function doEvalString(commands) { + const response = await commands.scriptCommand.execute( + "window.foobarObject.strfoo" + ); + + checkObject(response, { + input: "window.foobarObject.strfoo", + result: "foobarz", + }); +} + +async function doEvalLongString(commands) { + const response = await commands.scriptCommand.execute( + "window.foobarObject.omgstr" + ); + + const str = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function () { + return content.wrappedJSObject.foobarObject.omgstr; + } + ); + + const initial = str.substring(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH); + + checkObject(response, { + input: "window.foobarObject.omgstr", + result: { + type: "longString", + initial, + length: str.length, + }, + }); +} + +async function doEvalWithBinding(commands) { + const response = await commands.scriptCommand.execute("document;"); + const documentActor = response.result.actorID; + + info("running a command with _self as document using selectedObjectActor"); + const selectedObjectSame = await commands.scriptCommand.execute( + "_self === document", + { + selectedObjectActor: documentActor, + } + ); + checkObject(selectedObjectSame, { + result: true, + }); +} + +async function forceLexicalInit(commands) { + info("test that failed let/const bindings are initialized to undefined"); + + const testData = [ + { + stmt: "let foopie = wubbalubadubdub", + vars: ["foopie"], + }, + { + stmt: "let {z, w={n}=null} = {}", + vars: ["z", "w"], + }, + { + stmt: "let [a, b, c] = null", + vars: ["a", "b", "c"], + }, + { + stmt: "const nein1 = rofl, nein2 = copter", + vars: ["nein1", "nein2"], + }, + { + stmt: "const {ha} = null", + vars: ["ha"], + }, + { + stmt: "const [haw=[lame]=null] = []", + vars: ["haw"], + }, + { + stmt: "const [rawr, wat=[lame]=null] = []", + vars: ["rawr", "haw"], + }, + { + stmt: "let {zzz: xyz=99, zwz: wb} = nexistepas()", + vars: ["xyz", "wb"], + }, + { + stmt: "let {c3pdoh=101} = null", + vars: ["c3pdoh"], + }, + { + stmt: "const {...x} = x", + vars: ["x"], + }, + { + stmt: "const {xx,yy,...rest} = null", + vars: ["xx", "yy", "rest"], + }, + ]; + + for (const data of testData) { + const response = await commands.scriptCommand.execute(data.stmt); + checkObject(response, { + input: data.stmt, + result: { type: "undefined" }, + }); + ok(response.exception, "expected exception"); + for (const varName of data.vars) { + const response2 = await commands.scriptCommand.execute(varName); + checkObject(response2, { + input: varName, + result: { type: "undefined" }, + }); + ok(!response2.exception, "unexpected exception"); + } + } +} + +async function doSimpleEagerEval(commands) { + const testData = [ + { + code: "2+2", + result: 4, + }, + { + code: "(x => x * 2)(3)", + result: 6, + }, + { + code: "[1, 2, 3].map(x => x * 2).join()", + result: "2,4,6", + }, + { + code: `"abc".match(/a./)[0]`, + result: "ab", + }, + { + code: "aliasedTest()", + result: "ALIASED", + }, + { + code: "testArray.concat([4,5]).join()", + result: "1,2,3,4,5", + }, + { + code: "testArray.entries().toString()", + result: "[object Array Iterator]", + }, + { + code: "testArray.keys().toString()", + result: "[object Array Iterator]", + }, + { + code: "testArray.values().toString()", + result: "[object Array Iterator]", + }, + { + code: "testArray.every(x => x < 100)", + result: true, + }, + { + code: "testArray.some(x => x > 1)", + result: true, + }, + { + code: "testArray.filter(x => x % 2 == 0).join()", + result: "2", + }, + { + code: "testArray.find(x => x % 2 == 0)", + result: 2, + }, + { + code: "testArray.findIndex(x => x % 2 == 0)", + result: 1, + }, + { + code: "[testArray].flat().join()", + result: "1,2,3", + }, + { + code: "[testArray].flatMap(x => x).join()", + result: "1,2,3", + }, + { + code: "testArray.forEach(x => x); testArray.join()", + result: "1,2,3", + }, + { + code: "testArray.includes(1)", + result: true, + }, + { + code: "testArray.lastIndexOf(1)", + result: 0, + }, + { + code: "testArray.map(x => x + 1).join()", + result: "2,3,4", + }, + { + code: "testArray.reduce((acc,x) => acc + x, 0)", + result: 6, + }, + { + code: "testArray.reduceRight((acc,x) => acc + x, 0)", + result: 6, + }, + { + code: "testArray.slice(0,1).join()", + result: "1", + }, + { + code: "testArray.toReversed().join()", + result: "3,2,1", + }, + { + code: "testArray.toSorted().join()", + result: "1,2,3", + }, + { + code: "testArray.toSpliced(0,1).join()", + result: "2,3", + }, + { + code: "testArray.with(1, 'b').join()", + result: "1,b,3", + }, + + { + code: "testInt8Array.entries().toString()", + result: "[object Array Iterator]", + }, + { + code: "testInt8Array.keys().toString()", + result: "[object Array Iterator]", + }, + { + code: "testInt8Array.values().toString()", + result: "[object Array Iterator]", + }, + { + code: "testInt8Array.every(x => x < 100)", + result: true, + }, + { + code: "testInt8Array.some(x => x > 1)", + result: true, + }, + { + code: "testInt8Array.filter(x => x % 2 == 0).join()", + result: "2", + }, + { + code: "testInt8Array.find(x => x % 2 == 0)", + result: 2, + }, + { + code: "testInt8Array.findIndex(x => x % 2 == 0)", + result: 1, + }, + { + code: "testInt8Array.forEach(x => x); testInt8Array.join()", + result: "1,2,3", + }, + { + code: "testInt8Array.includes(1)", + result: true, + }, + { + code: "testInt8Array.lastIndexOf(1)", + result: 0, + }, + { + code: "testInt8Array.map(x => x + 1).join()", + result: "2,3,4", + }, + { + code: "testInt8Array.reduce((acc,x) => acc + x, 0)", + result: 6, + }, + { + code: "testInt8Array.reduceRight((acc,x) => acc + x, 0)", + result: 6, + }, + { + code: "testInt8Array.slice(0,1).join()", + result: "1", + }, + { + code: "testInt8Array.toReversed().join()", + skip: + typeof Reflect.getPrototypeOf(Int8Array).prototype.toReversed !== + "function", + result: "3,2,1", + }, + { + code: "testInt8Array.toSorted().join()", + skip: + typeof Reflect.getPrototypeOf(Int8Array).prototype.toSorted !== + "function", + result: "1,2,3", + }, + { + code: "testInt8Array.with(1, 0).join()", + skip: + typeof Reflect.getPrototypeOf(Int8Array).prototype.with !== "function", + result: "1,0,3", + }, + ]; + + for (const { code, result, skip } of testData) { + if (skip) { + info(`Skipping evaluation of ${code}`); + continue; + } + + info(`Evaluating: ${code}`); + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject(response, { + input: code, + result, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } +} + +async function doEagerEvalWithSideEffect(commands) { + const testData = [ + // Modify environment. + "var a = 10; a;", + + // Directly call a funtion with side effect. + "prompt();", + + // Call a funtion with side effect inside a scripted function. + "(() => { prompt(); })()", + + // Call a funtion with side effect from self-hosted JS function. + "[1, 2, 3].map(prompt)", + + // Call a function with Function.prototype.call. + "Function.prototype.call.bind(Function.prototype.call)(prompt);", + + // Call a function with Function.prototype.apply. + "Function.prototype.apply.bind(Function.prototype.apply)(prompt);", + + // Indirectly call a function with Function.prototype.apply. + "Reflect.apply(prompt, null, []);", + "'aaaaaaaa'.replace(/(a)(a)(a)(a)(a)(a)(a)(a)/, prompt)", + + // Indirect call on obj[Symbol.iterator]().next. + "Array.from(promptIterable)", + ]; + + for (const code of testData) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject(response, { + input: code, + result: { type: "undefined" }, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } +} + +async function doEagerEvalWithSideEffectIterator(commands) { + // Indirect call on %ArrayIterator%.prototype.next, + + // Create an iterable object that reuses iterator across multiple call. + let response = await commands.scriptCommand.execute(` +var arr = [1, 2, 3]; +var iterator = arr[Symbol.iterator](); +var iterable = { [Symbol.iterator]() { return iterator; } }; +"ok"; +`); + checkObject(response, { + result: "ok", + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + + const testData = [ + "Array.from(iterable)", + "new Map(iterable)", + "new Set(iterable)", + ]; + + for (const code of testData) { + response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject(response, { + input: code, + result: { type: "undefined" }, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } + + // Verify the iterator's internal state isn't modified. + response = await commands.scriptCommand.execute(`[...iterator].join(",")`); + checkObject(response, { + result: "1,2,3", + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); +} + +async function doEagerEvalWithSideEffectMonkeyPatched(commands) { + // Patch the built-in function without eager evaluation. + let response = await commands.scriptCommand.execute( + `RegExp.prototype.exec = prompt; "patched"` + ); + checkObject(response, { + result: "patched", + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + + // Test eager evaluation, where the patched built-in is called internally. + // This should be aborted. + const code = `"abc".match(/a./)[0]`; + response = await commands.scriptCommand.execute(code, { eager: true }); + checkObject(response, { + input: code, + result: { type: "undefined" }, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + + // Undo the patch without eager evaluation. + response = await commands.scriptCommand.execute( + `RegExp.prototype.exec = originalExec; "unpatched"` + ); + checkObject(response, { + result: "unpatched", + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + + // Test eager evaluation again, without the patch. + // This should be evaluated. + response = await commands.scriptCommand.execute(code, { eager: true }); + checkObject(response, { + input: code, + result: "ab", + }); +} + +async function doEagerEvalESGetters(commands) { + // [code, expectedResult] + const testData = [ + // ArrayBuffer + ["testArrayBuffer.byteLength", 3], + + // DataView + ["testDataView.buffer === testArrayBuffer", true], + ["testDataView.byteLength", 1], + ["testDataView.byteOffset", 2], + + // Error + ["typeof new Error().stack", "string"], + + // Function + ["typeof testFunc.arguments", "object"], + ["typeof testFunc.caller", "object"], + + // Intl.Locale + ["testLocale.baseName", "de-Latn-DE"], + ["testLocale.calendar", "gregory"], + ["testLocale.caseFirst", ""], + ["testLocale.collation", "phonebk"], + ["testLocale.hourCycle", "h23"], + ["testLocale.numeric", false], + ["testLocale.numberingSystem", "latn"], + ["testLocale.language", "de"], + ["testLocale.script", "Latn"], + ["testLocale.region", "DE"], + + // Map + ["testMap.size", 4], + + // RegExp + ["/a/.dotAll", false], + ["/a/giy.flags", "giy"], + ["/a/g.global", true], + ["/a/g.hasIndices", false], + ["/a/g.ignoreCase", false], + ["/a/g.multiline", false], + ["/a/g.source", "a"], + ["/a/g.sticky", false], + ["/a/g.unicode", false], + + // Set + ["testSet.size", 5], + + // Symbol + ["Symbol.iterator.description", "Symbol.iterator"], + + // TypedArray + ["testInt8Array.buffer === testArrayBuffer", true], + ["testInt8Array.byteLength", 3], + ["testInt8Array.byteOffset", 0], + ["testInt8Array.length", 3], + ["testInt8Array[Symbol.toStringTag]", "Int8Array"], + ]; + + for (const [code, expectedResult] of testData) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: expectedResult, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } + + // Test RegExp static properties. + // Run preparation code here to avoid interference with other tests, + // given RegExp static properties are global state. + const regexpPreparationCode = ` +/b(c)(d)(e)(f)(g)(h)(i)(j)(k)l/.test("abcdefghijklm") +`; + + const prepResponse = await commands.scriptCommand.execute( + regexpPreparationCode + ); + checkObject(prepResponse, { + input: regexpPreparationCode, + result: true, + }); + + ok(!prepResponse.exception, "no eval exception"); + ok(!prepResponse.helperResult, "no helper result"); + + const testDataRegExp = [ + // RegExp static + ["RegExp.input", "abcdefghijklm"], + ["RegExp.lastMatch", "bcdefghijkl"], + ["RegExp.lastParen", "k"], + ["RegExp.leftContext", "a"], + ["RegExp.rightContext", "m"], + ["RegExp.$1", "c"], + ["RegExp.$2", "d"], + ["RegExp.$3", "e"], + ["RegExp.$4", "f"], + ["RegExp.$5", "g"], + ["RegExp.$6", "h"], + ["RegExp.$7", "i"], + ["RegExp.$8", "j"], + ["RegExp.$9", "k"], + ["RegExp.$_", "abcdefghijklm"], // input + ["RegExp['$&']", "bcdefghijkl"], // lastMatch + ["RegExp['$+']", "k"], // lastParen + ["RegExp['$`']", "a"], // leftContext + ["RegExp[`$'`]", "m"], // rightContext + ]; + + for (const [code, expectedResult] of testDataRegExp) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: expectedResult, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } + + const testDataWithSideEffect = [ + // get Object.prototype.__proto__ + // + // This can invoke Proxy getPrototypeOf handler, which can be any native + // function, and debugger cannot hook the call. + `[].__proto__`, + `testProxy.__proto__`, + ]; + + for (const code of testDataWithSideEffect) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: { type: "undefined" }, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } +} + +async function doEagerEvalDOMGetters(commands) { + // Getters explicitly marked no-side-effect. + // + // [code, expectedResult] + const testDataExplicit = [ + // DOMTokenList + ["document.documentElement.classList.length", 1], + ["document.documentElement.classList.value", "class1"], + + // Document + ["document.URL.startsWith('data:')", true], + ["document.documentURI.startsWith('data:')", true], + ["document.compatMode", "CSS1Compat"], + ["document.characterSet", "UTF-8"], + ["document.charset", "UTF-8"], + ["document.inputEncoding", "UTF-8"], + ["document.contentType", "text/html"], + ["document.doctype.constructor.name", "DocumentType"], + ["document.documentElement.constructor.name", "HTMLHtmlElement"], + ["document.title", "Testcase"], + ["document.dir", "ltr"], + ["document.body.constructor.name", "HTMLBodyElement"], + ["document.head.constructor.name", "HTMLHeadElement"], + ["document.images.constructor.name", "HTMLCollection"], + ["document.embeds.constructor.name", "HTMLCollection"], + ["document.plugins.constructor.name", "HTMLCollection"], + ["document.links.constructor.name", "HTMLCollection"], + ["document.forms.constructor.name", "HTMLCollection"], + ["document.scripts.constructor.name", "HTMLCollection"], + ["document.defaultView === window", true], + ["typeof document.currentScript", "object"], + ["document.anchors.constructor.name", "HTMLCollection"], + ["document.applets.constructor.name", "HTMLCollection"], + ["document.all.constructor.name", "HTMLAllCollection"], + ["document.styleSheetSets.constructor.name", "DOMStringList"], + ["typeof document.featurePolicy", "undefined"], + ["typeof document.blockedNodeByClassifierCount", "undefined"], + ["typeof document.blockedNodesByClassifier", "undefined"], + ["typeof document.permDelegateHandler", "undefined"], + ["document.children.constructor.name", "HTMLCollection"], + ["document.firstElementChild === document.documentElement", true], + ["document.lastElementChild === document.documentElement", true], + ["document.childElementCount", 1], + ["document.location.href.startsWith('data:')", true], + + // Element + ["document.body.namespaceURI", "http://www.w3.org/1999/xhtml"], + ["document.body.prefix === null", true], + ["document.body.localName", "body"], + ["document.body.tagName", "BODY"], + ["document.body.id", "body1"], + ["document.body.className", "class2"], + ["document.body.classList.constructor.name", "DOMTokenList"], + ["document.body.part.constructor.name", "DOMTokenList"], + ["document.body.attributes.constructor.name", "NamedNodeMap"], + ["document.body.innerHTML.includes('Body text')", true], + ["document.body.outerHTML.includes('Body text')", true], + ["document.body.previousElementSibling !== null", true], + ["document.body.nextElementSibling === null", true], + ["document.body.children.constructor.name", "HTMLCollection"], + ["document.body.firstElementChild !== null", true], + ["document.body.lastElementChild !== null", true], + ["document.body.childElementCount", 1], + + // Node + ["document.body.nodeType === Node.ELEMENT_NODE", true], + ["document.body.nodeName", "BODY"], + ["document.body.baseURI.startsWith('data:')", true], + ["document.body.isConnected", true], + ["document.body.ownerDocument === document", true], + ["document.body.parentNode === document.documentElement", true], + ["document.body.parentElement === document.documentElement", true], + ["document.body.childNodes.constructor.name", "NodeList"], + ["document.body.firstChild !== null", true], + ["document.body.lastChild !== null", true], + ["document.body.previousSibling !== null", true], + ["document.body.nextSibling === null", true], + ["document.body.nodeValue === null", true], + ["document.body.textContent.includes('Body text')", true], + ["typeof document.body.flattenedTreeParentNode", "undefined"], + ["typeof document.body.isNativeAnonymous", "undefined"], + ["typeof document.body.containingShadowRoot", "undefined"], + ["typeof document.body.accessibleNode", "undefined"], + + // Performance + ["performance.timeOrigin > 0", true], + ["performance.timing.constructor.name", "PerformanceTiming"], + ["performance.navigation.constructor.name", "PerformanceNavigation"], + ["performance.eventCounts.constructor.name", "EventCounts"], + + // window + ["window.window === window", true], + ["window.self === window", true], + ["window.document.constructor.name", "HTMLDocument"], + ["window.performance.constructor.name", "Performance"], + ["typeof window.browsingContext", "undefined"], + ["typeof window.windowUtils", "undefined"], + ["typeof window.windowGlobalChild", "undefined"], + ["window.visualViewport.constructor.name", "VisualViewport"], + ["typeof window.caches", "undefined"], + ["window.location.href.startsWith('data:')", true], + ]; + if (typeof Scheduler === "function") { + // Scheduler is behind a pref. + testDataExplicit.push(["window.scheduler.constructor.name", "Scheduler"]); + } + + for (const [code, expectedResult] of testDataExplicit) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: expectedResult, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } + + // Getters not-explicitly marked no-side-effect. + // All DOM getters are considered no-side-effect in eager evaluation context. + const testDataImplicit = [ + // NOTE: This is not an exhaustive list. + // Document + [`document.implementation.constructor.name`, "DOMImplementation"], + [`typeof document.domain`, "string"], + [`typeof document.referrer`, "string"], + [`typeof document.cookie`, "string"], + [`typeof document.lastModified`, "string"], + [`typeof document.readyState`, "string"], + [`typeof document.designMode`, "string"], + [`typeof document.onbeforescriptexecute`, "object"], + [`typeof document.onafterscriptexecute`, "object"], + + // Element + [`typeof document.documentElement.scrollTop`, "number"], + [`typeof document.documentElement.scrollLeft`, "number"], + [`typeof document.documentElement.scrollWidth`, "number"], + [`typeof document.documentElement.scrollHeight`, "number"], + + // Performance + [`typeof performance.onresourcetimingbufferfull`, "object"], + + // window + [`typeof window.name`, "string"], + [`window.history.constructor.name`, "History"], + [`window.customElements.constructor.name`, "CustomElementRegistry"], + [`window.locationbar.constructor.name`, "BarProp"], + [`window.menubar.constructor.name`, "BarProp"], + [`typeof window.status`, "string"], + [`window.closed`, false], + + // CanvasRenderingContext2D / CanvasCompositing + [`testCanvasContext.globalAlpha`, 1], + ]; + + for (const [code, expectedResult] of testDataImplicit) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: expectedResult, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } +} + +async function doEagerEvalOtherNativeGetters(commands) { + // DOM getter functions are allowed to be eagerly-evaluated. + // Test the situation where non-DOM-getter function is called by accessing + // getter. + // + // "being a DOM getter" is tested by checking if the native function has + // JSJitInfo and it's marked as getter. + const testData = [ + // Has no JitInfo. + "objWithNativeGetter.print", + "objWithNativeGetter.Element", + + // Not marked as getter, but method. + "objWithNativeGetter.getAttribute", + + // Not marked as getter, but setter. + "objWithNativeGetter.setClassName", + + // Not marked as getter, but static method. + "objWithNativeGetter.requestPermission", + ]; + + for (const code of testData) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: { type: "undefined" }, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } +} + +async function doEagerEvalAsyncFunctions(commands) { + // [code, expectedResult] + const testData = [["typeof testAsync()", "object"]]; + + for (const [code, expectedResult] of testData) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: expectedResult, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } + + const testDataWithSideEffect = [ + // await is effectful + "testAsyncAwait()", + + // initial yield is effectful + "testAsyncGen()", + "testAsyncGenAwait()", + ]; + + for (const code of testDataWithSideEffect) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: { type: "undefined" }, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } +} diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js b/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js new file mode 100644 index 0000000000..28f56ebac3 --- /dev/null +++ b/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing evaluating document.__proto__ + +add_task(async () => { + const tab = await addTab( + `data:text/html;charset=utf-8,Test evaluating document.__proto__` + ); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const evaluationResponse = await commands.scriptCommand.execute( + "document.__proto__" + ); + checkObject(evaluationResponse, { + input: "document.__proto__", + result: { + type: "object", + actor: /[a-z]/, + }, + }); + + ok(!evaluationResponse.exception, "no eval exception"); + ok(!evaluationResponse.helperResult, "no helper result"); + + const response = await evaluationResponse.result.getPrototypeAndProperties(); + ok(!response.error, "no response error"); + + const props = response.ownProperties; + ok(props, "response properties available"); + + const expectedProps = Object.getOwnPropertyNames( + Object.getPrototypeOf(document) + ); + checkObject(Object.keys(props), expectedProps, "Same own properties."); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js b/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js new file mode 100644 index 0000000000..aebdaeb168 --- /dev/null +++ b/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing that last evaluation result can be accessed with `$_` + +add_task(async () => { + const tab = await addTab(`data:text/html;charset=utf-8,`); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + info("$_ returns undefined if nothing has evaluated yet"); + let response = await commands.scriptCommand.execute("$_"); + basicResultCheck(response, "$_", { type: "undefined" }); + + info("$_ returns last value and performs basic arithmetic"); + response = await commands.scriptCommand.execute("2+2"); + basicResultCheck(response, "2+2", 4); + + response = await commands.scriptCommand.execute("$_"); + basicResultCheck(response, "$_", 4); + + response = await commands.scriptCommand.execute("$_ + 2"); + basicResultCheck(response, "$_ + 2", 6); + + response = await commands.scriptCommand.execute("$_ + 4"); + basicResultCheck(response, "$_ + 4", 10); + + info("$_ has correct references to objects"); + response = await commands.scriptCommand.execute("var foo = {bar:1}; foo;"); + basicResultCheck(response, "var foo = {bar:1}; foo;", { + type: "object", + class: "Object", + actor: /[a-z]/, + }); + checkObject(response.result.getGrip().preview.ownProperties, { + bar: { + value: 1, + }, + }); + + response = await commands.scriptCommand.execute("$_"); + basicResultCheck(response, "$_", { + type: "object", + class: "Object", + actor: /[a-z]/, + }); + checkObject(response.result.getGrip().preview.ownProperties, { + bar: { + value: 1, + }, + }); + + info( + "Update a property value and check that evaluating $_ returns the expected object instance" + ); + await ContentTask.spawn(gBrowser.selectedBrowser, null, () => { + content.wrappedJSObject.foo.bar = "updated_value"; + }); + + response = await commands.scriptCommand.execute("$_"); + basicResultCheck(response, "$_", { + type: "object", + class: "Object", + actor: /[a-z]/, + }); + checkObject(response.result.getGrip().preview.ownProperties, { + bar: { + value: "updated_value", + }, + }); + + await commands.destroy(); +}); + +function basicResultCheck(response, input, output) { + checkObject(response, { + input, + result: output, + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); +} diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js b/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js new file mode 100644 index 0000000000..8680193ecb --- /dev/null +++ b/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing evaluating thowing expressions +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +add_task(async () => { + const tab = await addTab(`data:text/html;charset=utf-8,Test throw`); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const falsyValues = [ + "-0", + "null", + "undefined", + "Infinity", + "-Infinity", + "NaN", + ]; + for (const value of falsyValues) { + const response = await commands.scriptCommand.execute(`throw ${value};`); + is( + response.exception.type, + value, + `Got the expected value for response.exception.type when throwing "${value}"` + ); + } + + const identityTestValues = [false, 0]; + for (const value of identityTestValues) { + const response = await commands.scriptCommand.execute(`throw ${value};`); + is( + response.exception, + value, + `Got the expected value for response.exception when throwing "${value}"` + ); + } + + const symbolTestValues = [ + ["Symbol.iterator", "Symbol(Symbol.iterator)"], + ["Symbol('foo')", "Symbol(foo)"], + ["Symbol()", "Symbol()"], + ]; + for (const [expr, message] of symbolTestValues) { + const response = await commands.scriptCommand.execute(`throw ${expr};`); + is( + response.exceptionMessage, + message, + `Got the expected value for response.exceptionMessage when throwing "${expr}"` + ); + } + + const longString = Array(DevToolsServer.LONG_STRING_LENGTH + 1).join("a"), + shortedString = longString.substring( + 0, + DevToolsServer.LONG_STRING_INITIAL_LENGTH + ); + const response = await commands.scriptCommand.execute( + "throw '" + longString + "';" + ); + is( + response.exception.initial, + shortedString, + "Got the expected value for exception.initial when throwing a longString" + ); + is( + response.exceptionMessage.initial, + shortedString, + "Got the expected value for exceptionMessage.initial when throwing a longString" + ); +}); diff --git a/devtools/shared/commands/script/tests/head.js b/devtools/shared/commands/script/tests/head.js new file mode 100644 index 0000000000..50635e4502 --- /dev/null +++ b/devtools/shared/commands/script/tests/head.js @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +function checkObject(object, expected, message) { + if (object && object.getGrip) { + object = object.getGrip(); + } + + for (const name of Object.keys(expected)) { + const expectedValue = expected[name]; + const value = object[name]; + checkValue(name, value, expectedValue, message); + } +} + +function checkValue(name, value, expected, message) { + if (message) { + message = ` for '${message}'`; + } + + if (expected === null) { + is(value, null, `'${name}' is null${message}`); + } else if (expected === undefined) { + is(value, expected, `'${name}' is undefined${message}`); + } else if ( + typeof expected == "string" || + typeof expected == "number" || + typeof expected == "boolean" + ) { + is(value, expected, "property '" + name + "'" + message); + } else if (expected instanceof RegExp) { + ok( + expected.test(value), + name + ": " + expected + " matched " + value + message + ); + } else if (Array.isArray(expected)) { + info("checking array for property '" + name + "'" + message); + checkObject(value, expected, message); + } else if (typeof expected == "object") { + info("checking object for property '" + name + "'" + message); + checkObject(value, expected, message); + } +} diff --git a/devtools/shared/commands/target-configuration/moz.build b/devtools/shared/commands/target-configuration/moz.build new file mode 100644 index 0000000000..c0929aee77 --- /dev/null +++ b/devtools/shared/commands/target-configuration/moz.build @@ -0,0 +1,10 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "target-configuration-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/target-configuration/target-configuration-command.js b/devtools/shared/commands/target-configuration/target-configuration-command.js new file mode 100644 index 0000000000..28e717cea2 --- /dev/null +++ b/devtools/shared/commands/target-configuration/target-configuration-command.js @@ -0,0 +1,124 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * The TargetConfigurationCommand should be used to populate the DevTools server + * with settings read from the client side but which impact the server. + * For instance, "disable cache" is a feature toggled via DevTools UI (client), + * but which should be communicated to the targets (server). + * + * See the TargetConfigurationActor for a list of supported configuration options. + */ +class TargetConfigurationCommand { + constructor({ commands, watcherFront }) { + this._commands = commands; + this._watcherFront = watcherFront; + } + + /** + * Return a promise that resolves to the related target configuration actor's front. + * + * @return {Promise<TargetConfigurationFront>} + */ + async getFront() { + const front = await this._watcherFront.getTargetConfigurationActor(); + + if (!this._configuration) { + // Retrieve initial data from the front + this._configuration = front.initialConfiguration; + } + + return front; + } + + _hasTargetWatcherSupport() { + return this._commands.targetCommand.hasTargetWatcherSupport(); + } + + /** + * Retrieve the current map of configuration options pushed to the server. + */ + get configuration() { + return this._configuration || {}; + } + + async updateConfiguration(configuration) { + if (this._hasTargetWatcherSupport()) { + const front = await this.getFront(); + const updatedConfiguration = await front.updateConfiguration( + configuration + ); + // Update the client-side copy of the DevTools configuration + this._configuration = updatedConfiguration; + } else { + await this._commands.targetCommand.targetFront.reconfigure({ + options: configuration, + }); + } + } + + async isJavascriptEnabled() { + // If we don't have target watcher support, we can't get this value, so just + // fall back to true. Only content tab targets can update javascriptEnabled + // and all should have watcher support. + if (!this._hasTargetWatcherSupport()) { + return true; + } + + const front = await this.getFront(); + return front.isJavascriptEnabled(); + } + + /** + * Reports if the given configuration key is supported by the server. + * If the debugged context doesn't support the watcher actor, + * we won't be using the target configuration actor and report all keys + * as not supported. + * + * @param {Object} configurationKey + * Name of the configuration you would like to set. + * @return {Promise<Boolean>} True, if this configuration can be set via this API. + */ + async supports(configurationKey) { + if (!this._hasTargetWatcherSupport()) { + return false; + } + const front = await this.getFront(); + return !!front.traits.supportedOptions[configurationKey]; + } + + /** + * Change orientation type and angle (that can be accessed through screen.orientation in + * the content page) and simulates the "orientationchange" event when the device screen + * was rotated. + * Note that this will only be effective if the Responsive Design Mode is enabled. + * + * @param {Object} options + * @param {String} options.type: The orientation type of the rotated device. + * @param {Number} options.angle: The rotated angle of the device. + * @param {Boolean} options.isViewportRotated: Whether or not screen orientation change + * is a result of rotating the viewport. If true, an "orientationchange" + * event will be dispatched in the content window. + */ + async simulateScreenOrientationChange({ type, angle, isViewportRotated }) { + // We need to call the method on the parent process + await this.updateConfiguration({ + rdmPaneOrientation: { type, angle }, + }); + + // Don't dispatch the "orientationchange" event if orientation change is a result + // of switching to a new device, location change, or opening RDM. + if (!isViewportRotated) { + return; + } + + const responsiveFront = + await this._commands.targetCommand.targetFront.getFront("responsive"); + await responsiveFront.dispatchOrientationChangeEvent(); + } +} + +module.exports = TargetConfigurationCommand; diff --git a/devtools/shared/commands/target-configuration/tests/browser.toml b/devtools/shared/commands/target-configuration/tests/browser.toml new file mode 100644 index 0000000000..d13ac34bd6 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser.toml @@ -0,0 +1,34 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "target_configuration_test_doc.sjs", + "head.js", +] + +["browser_target_configuration_command.js"] + +["browser_target_configuration_command_color_scheme.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_target_configuration_command_custom_user_agent.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_target_configuration_command_dppx.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_target_configuration_command_touch_events.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js new file mode 100644 index 0000000000..47dab1baa9 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the watcher's target-configuration actor API. + +add_task(async function () { + info("Setup the test page with workers of all types"); + const tab = await addTab("data:text/html;charset=utf-8,Configuration actor"); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + compareOptions( + targetConfigurationCommand.configuration, + {}, + "Initial configuration is empty" + ); + + await targetConfigurationCommand.updateConfiguration({ + cacheDisabled: true, + }); + compareOptions( + targetConfigurationCommand.configuration, + { cacheDisabled: true }, + "Option cacheDisabled was set" + ); + + await targetConfigurationCommand.updateConfiguration({ + javascriptEnabled: false, + }); + compareOptions( + targetConfigurationCommand.configuration, + { cacheDisabled: true, javascriptEnabled: false }, + "Option javascriptEnabled was set" + ); + + await targetConfigurationCommand.updateConfiguration({ + cacheDisabled: false, + }); + compareOptions( + targetConfigurationCommand.configuration, + { cacheDisabled: false, javascriptEnabled: false }, + "Option cacheDisabled was updated" + ); + + await targetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + compareOptions( + targetConfigurationCommand.configuration, + { + cacheDisabled: false, + colorSchemeSimulation: "dark", + javascriptEnabled: false, + }, + "Option colorSchemeSimulation was set, with a string value" + ); + + await targetConfigurationCommand.updateConfiguration({ + setTabOffline: true, + }); + compareOptions( + targetConfigurationCommand.configuration, + { + cacheDisabled: false, + colorSchemeSimulation: "dark", + javascriptEnabled: false, + setTabOffline: true, + }, + "Option setTabOffline was set on" + ); + + await targetConfigurationCommand.updateConfiguration({ + setTabOffline: false, + }); + compareOptions( + targetConfigurationCommand.configuration, + { + setTabOffline: false, + cacheDisabled: false, + colorSchemeSimulation: "dark", + javascriptEnabled: false, + }, + "Option setTabOffline was set off" + ); + + targetCommand.destroy(); + await commands.destroy(); +}); + +function compareOptions(options, expected, message) { + is( + Object.keys(options).length, + Object.keys(expected).length, + message + " (wrong number of options)" + ); + + for (const key of Object.keys(expected)) { + is(options[key], expected[key], message + ` (wrong value for ${key})`); + } +} diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_color_scheme.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_color_scheme.js new file mode 100644 index 0000000000..509ecb84b2 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_color_scheme.js @@ -0,0 +1,183 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test color scheme simulation. +const TEST_DOCUMENT = "target_configuration_test_doc.sjs"; +const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT; + +add_task(async function () { + info("Setup the test page with workers of all types"); + const tab = await addTab(TEST_URI); + + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + false, + "The dark mode simulation wasn't enabled in the content page when it loaded" + ); + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + false, + "The dark mode simulation isn't enabled in the content page by default" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + false, + "The dark mode simulation wasn't enabled in the remote iframe when it loaded" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + false, + "The dark mode simulation isn't enabled in the remote iframe by default" + ); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + info("Update configuration to enable dark mode simulation"); + await targetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled after updating the configuration" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the remote iframe after updating the configuration" + ); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + true, + "The dark mode simulation was enabled in the content page when it loaded after reloading" + ); + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the content page after reloading" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + true, + "The dark mode simulation was enabled in the remote iframe when it loaded after reloading" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the remote iframe after reloading" + ); + + const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id; + info( + "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled" + ); + + const onPageLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + /* includeSubFrames */ true + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true" + ); + await onPageLoaded; + + isnot( + gBrowser.selectedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + true, + "The dark mode simulation was enabled in the content page when it loaded after navigating to a new browsing context" + ); + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the content page after navigating to a new browsing context" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + true, + "The dark mode simulation was enabled in the remote iframe when it loaded after navigating to a new browsing context" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the remote iframe after navigating to a new browsing context" + ); + + targetCommand.destroy(); + await commands.destroy(); + + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + false, + "The dark mode simulation is disabled in the content page after destroying the commands" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + false, + "The dark mode simulation is disabled in the remote iframe after destroying the commands" + ); +}); + +function matchPrefersDarkColorSchemeMedia(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.matchMedia("(prefers-color-scheme: dark)").matches + ); +} + +function matchPrefersDarkColorSchemeMediaAtStartup(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.wrappedJSObject.initialMatchesPrefersDarkColorScheme + ); +} + +function topLevelDocumentMatchPrefersDarkColorSchemeMedia() { + return matchPrefersDarkColorSchemeMedia(gBrowser.selectedBrowser); +} + +function topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup() { + return matchPrefersDarkColorSchemeMediaAtStartup(gBrowser.selectedBrowser); +} + +function getIframeBrowsingContext() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + // Ensure we've rendered the iframe so that the prefers-color-scheme + // value propagated from the embedder is up-to-date. + await new Promise(resolve => { + content.requestAnimationFrame(() => + content.requestAnimationFrame(resolve) + ); + }); + return content.document.querySelector("iframe").browsingContext; + }); +} + +async function iframeDocumentMatchPrefersDarkColorSchemeMedia() { + const iframeBC = await getIframeBrowsingContext(); + return matchPrefersDarkColorSchemeMedia(iframeBC); +} + +async function iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup() { + const iframeBC = await getIframeBrowsingContext(); + return matchPrefersDarkColorSchemeMediaAtStartup(iframeBC); +} diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_custom_user_agent.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_custom_user_agent.js new file mode 100644 index 0000000000..3f342a1ac9 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_custom_user_agent.js @@ -0,0 +1,309 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test setting custom user agent. +const TEST_DOCUMENT = "target_configuration_test_doc.sjs"; +const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + info("Create commands for the tab"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const initialUserAgent = await getTopLevelUserAgent(); + + info("Update configuration to change user agent"); + const CUSTOM_USER_AGENT = "<MY_BORING_CUSTOM_USER_AGENT>"; + + await targetConfigurationCommand.updateConfiguration({ + customUserAgent: CUSTOM_USER_AGENT, + }); + + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The user agent is properly set on the top level document after updating the configuration" + ); + is( + await getUserAgentForTopLevelRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources on the top level document" + ); + + is( + await getIframeUserAgent(), + CUSTOM_USER_AGENT, + "The user agent is properly set on the iframe after updating the configuration" + ); + is( + await getUserAgentForIframeRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources on the iframe" + ); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + + is( + await getTopLevelDocumentUserAgentAtStartup(), + CUSTOM_USER_AGENT, + "The custom user agent was set in the content page when it loaded after reloading" + ); + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is set in the content page after reloading" + ); + is( + await getUserAgentForTopLevelRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources after reloading" + ); + is( + await getIframeUserAgentAtStartup(), + CUSTOM_USER_AGENT, + "The custom user agent was set in the remote iframe when it loaded after reloading" + ); + is( + await getIframeUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is set in the remote iframe after reloading" + ); + is( + await getUserAgentForIframeRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources in the remote iframe after reloading" + ); + + const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id; + info( + "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled" + ); + + const onPageLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + /* includeSubFrames */ true + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true" + ); + await onPageLoaded; + + isnot( + gBrowser.selectedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + await getTopLevelDocumentUserAgentAtStartup(), + CUSTOM_USER_AGENT, + "The custom user agent was set in the content page when it loaded after navigating to a new browsing context" + ); + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is set in the content page after navigating to a new browsing context" + ); + is( + await getUserAgentForTopLevelRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources after navigating to a new browsing context" + ); + is( + await getIframeUserAgentAtStartup(), + CUSTOM_USER_AGENT, + "The custom user agent was set in the remote iframe when it loaded after navigating to a new browsing context" + ); + is( + await getIframeUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is set in the remote iframe after navigating to a new browsing context" + ); + is( + await getUserAgentForTopLevelRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources in the remote iframes after navigating to a new browsing context" + ); + + info( + "Create another commands instance and check that destroying it won't reset the user agent" + ); + const otherCommands = await CommandsFactory.forTab(tab); + const otherTargetConfigurationCommand = + otherCommands.targetConfigurationCommand; + const otherTargetCommand = otherCommands.targetCommand; + await otherTargetCommand.startListening(); + // wait for the target to be fully attached to avoid pending connection to the server + await otherTargetCommand.watchTargets({ + types: [otherTargetCommand.TYPES.FRAME], + onAvailable: () => {}, + }); + + // Let's update the configuration with this commands instance to make sure we hit the TargetConfigurationActor + await otherTargetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + + otherTargetCommand.destroy(); + await otherCommands.destroy(); + + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is still set on the page after destroying another commands instance" + ); + + info( + "Check that destroying the commands we set the user agent in will reset the user agent" + ); + targetCommand.destroy(); + await commands.destroy(); + + // XXX: This is needed at the moment since Navigator.cpp retrieve the UserAgent from the + // headers (when there's no custom user agent). And here, since we reloaded the page once + // we set the custom user agent, the header was set accordingly and still holds the custom + // user agent value. This should be fixed by Bug 1705326. + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is still set on the page after destroying the first commands instance. Bug 1705326 will fix that and make it equal to `initialUserAgent`" + ); + + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + is( + await getTopLevelUserAgent(), + initialUserAgent, + "The user agent was reset in the content page after destroying the commands" + ); + is( + await getIframeUserAgent(), + initialUserAgent, + "The user agent was reset in the remote iframe after destroying the commands" + ); + + // We need commands to retrieve the headers of the network request, and + // all those we created so far were destroyed; let's create new ones. + const newCommands = await CommandsFactory.forTab(tab); + await newCommands.targetCommand.startListening(); + is( + await getUserAgentForTopLevelRequest(newCommands), + initialUserAgent, + "The initial user agent is used when retrieving resources after destroying the commands" + ); + is( + await getUserAgentForIframeRequest(newCommands), + initialUserAgent, + "The initial user agent is used when retrieving resources on the remote iframe after destroying the commands" + ); +}); + +function getUserAgent(browserOrBrowsingContext) { + return SpecialPowers.spawn(browserOrBrowsingContext, [], () => { + return content.navigator.userAgent; + }); +} + +function getUserAgentAtStartup(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.wrappedJSObject.initialUserAgent + ); +} + +function getTopLevelUserAgent() { + return getUserAgent(gBrowser.selectedBrowser); +} + +function getTopLevelDocumentUserAgentAtStartup() { + return getUserAgentAtStartup(gBrowser.selectedBrowser); +} + +function getIframeBrowsingContext() { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); +} + +async function getIframeUserAgent() { + const iframeBC = await getIframeBrowsingContext(); + return getUserAgent(iframeBC); +} + +async function getIframeUserAgentAtStartup() { + const iframeBC = await getIframeBrowsingContext(); + return getUserAgentAtStartup(iframeBC); +} + +async function getRequestUserAgent(commands, browserOrBrowsingContext) { + const url = `unknown?${Date.now()}`; + + // Wait for the resource and its headers to be available + const onAvailable = () => {}; + let onUpdated; + + const onResource = new Promise(resolve => { + onUpdated = updates => { + for (const { resource } of updates) { + if (resource.url.includes(url) && resource.requestHeadersAvailable) { + resolve(resource); + } + } + }; + + commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.NETWORK_EVENT], + { + onAvailable, + onUpdated, + ignoreExistingResources: true, + } + ); + }); + + info(`Fetch ${url}`); + SpecialPowers.spawn(browserOrBrowsingContext, [url], innerUrl => { + content.fetch(`./${innerUrl}`); + }); + info("waiting for matching resource…"); + const networkResource = await onResource; + + info("…got resource, retrieve headers"); + const packet = { + to: networkResource.actor, + type: "getRequestHeaders", + }; + + const { headers } = await commands.client.request(packet); + + commands.resourceCommand.unwatchResources( + [commands.resourceCommand.TYPES.NETWORK_EVENT], + { + onAvailable, + onUpdated, + ignoreExistingResources: true, + } + ); + + return headers.find(header => header.name == "User-Agent")?.value; +} + +async function getUserAgentForTopLevelRequest(commands) { + return getRequestUserAgent(commands, gBrowser.selectedBrowser); +} + +async function getUserAgentForIframeRequest(commands) { + const iframeBC = await getIframeBrowsingContext(); + return getRequestUserAgent(commands, iframeBC); +} diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_dppx.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_dppx.js new file mode 100644 index 0000000000..744ac2c403 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_dppx.js @@ -0,0 +1,187 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test device pixel ratio override. +const TEST_DOCUMENT = "target_configuration_test_doc.sjs"; +const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + info("Create commands for the tab"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const originalDpr = await getTopLevelDocumentDevicePixelRatio(); + + info("Update configuration to change device pixel ratio"); + const CUSTOM_DPR = 5.5; + + await targetConfigurationCommand.updateConfiguration({ + overrideDPPX: CUSTOM_DPR, + }); + + is( + await getTopLevelDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The ratio is properly set on the top level document after updating the configuration" + ); + is( + await getIframeDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The ratio is properly set on the iframe after updating the configuration" + ); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + + is( + await getTopLevelDocumentDevicePixelRatioAtStartup(), + CUSTOM_DPR, + "The custom ratio was set in the content page when it loaded after reloading" + ); + is( + await getTopLevelDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is set in the content page after reloading" + ); + is( + await getIframeDocumentDevicePixelRatioAtStartup(), + CUSTOM_DPR, + "The custom ratio was set in the remote iframe when it loaded after reloading" + ); + is( + await getIframeDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is set in the remote iframe after reloading" + ); + + const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id; + info( + "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled" + ); + + const onPageLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + /* includeSubFrames */ true + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true" + ); + await onPageLoaded; + + isnot( + gBrowser.selectedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + await getTopLevelDocumentDevicePixelRatioAtStartup(), + CUSTOM_DPR, + "The custom ratio was set in the content page when it loaded after navigating to a new browsing context" + ); + is( + await getTopLevelDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is set in the content page after navigating to a new browsing context" + ); + is( + await getIframeDocumentDevicePixelRatioAtStartup(), + CUSTOM_DPR, + "The custom ratio was set in the remote iframe when it loaded after navigating to a new browsing context" + ); + is( + await getIframeDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is set in the remote iframe after navigating to a new browsing context" + ); + + info( + "Create another commands instance and check that destroying it won't reset the ratio" + ); + const otherCommands = await CommandsFactory.forTab(tab); + const otherTargetConfigurationCommand = + otherCommands.targetConfigurationCommand; + const otherTargetCommand = otherCommands.targetCommand; + await otherTargetCommand.startListening(); + + // Let's update the configuration with this commands instance to make sure we hit the TargetConfigurationActor + await otherTargetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + + otherTargetCommand.destroy(); + await otherCommands.destroy(); + + is( + await getTopLevelDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is still set on the page after destroying another commands instance" + ); + + info( + "Check that destroying the commands we overrode the ratio in will reset the page ratio" + ); + targetCommand.destroy(); + await commands.destroy(); + + is( + await getTopLevelDocumentDevicePixelRatio(), + originalDpr, + "The ratio was reset in the content page after destroying the commands" + ); + is( + await getIframeDocumentDevicePixelRatio(), + originalDpr, + "The ratio was reset in the remote iframe after destroying the commands" + ); +}); + +function getDevicePixelRatio(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.browsingContext.top.overrideDPPX || content.devicePixelRatio + ); +} + +function getDevicePixelRatioAtStartup(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.wrappedJSObject.initialDevicePixelRatio + ); +} + +function getTopLevelDocumentDevicePixelRatio() { + return getDevicePixelRatio(gBrowser.selectedBrowser); +} + +function getTopLevelDocumentDevicePixelRatioAtStartup() { + return getDevicePixelRatioAtStartup(gBrowser.selectedBrowser); +} + +function getIframeBrowsingContext() { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); +} + +async function getIframeDocumentDevicePixelRatio() { + const iframeBC = await getIframeBrowsingContext(); + return getDevicePixelRatio(iframeBC); +} + +async function getIframeDocumentDevicePixelRatioAtStartup() { + const iframeBC = await getIframeBrowsingContext(); + return getDevicePixelRatioAtStartup(iframeBC); +} diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_touch_events.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_touch_events.js new file mode 100644 index 0000000000..683dd6d999 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_touch_events.js @@ -0,0 +1,264 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test touch event simulation. +const TEST_DOCUMENT = "target_configuration_test_doc.sjs"; +const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT; + +add_task(async function () { + // Disable click hold and double tap zooming as it might interfere with the test + await pushPref("ui.click_hold_context_menus", false); + await pushPref("apz.allow_double_tap_zooming", false); + + const tab = await addTab(TEST_URI); + + info("Create commands for the tab"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + info("Touch simulation is disabled at the beginning"); + await checkTopLevelDocumentTouchSimulation({ enabled: false }); + await checkIframeTouchSimulation({ + enabled: false, + }); + + info("Enable touch simulation"); + await targetConfigurationCommand.updateConfiguration({ + touchEventsOverride: "enabled", + }); + await checkTopLevelDocumentTouchSimulation({ enabled: true }); + await checkIframeTouchSimulation({ + enabled: true, + }); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + + is( + await topLevelDocumentMatchesCoarsePointerAtStartup(), + true, + "The touch simulation was enabled in the content page when it loaded after reloading" + ); + await checkTopLevelDocumentTouchSimulation({ enabled: true }); + + is( + await iframeMatchesCoarsePointerAtStartup(), + true, + "The touch simulation was enabled in the iframe when it loaded after reloading" + ); + await checkIframeTouchSimulation({ + enabled: true, + }); + + info( + "Create another commands instance and check that destroying it won't reset the touch simulation" + ); + const otherCommands = await CommandsFactory.forTab(tab); + const otherTargetConfigurationCommand = + otherCommands.targetConfigurationCommand; + const otherTargetCommand = otherCommands.targetCommand; + + await otherTargetCommand.startListening(); + // Watch targets so we wait for server communication to settle (e.g. attach calls), as + // this could cause intermittent failures. + await otherTargetCommand.watchTargets({ + types: [otherTargetCommand.TYPES.FRAME], + onAvailable: () => {}, + }); + + // Let's update the configuration with this commands instance to make sure we hit the TargetConfigurationActor + await otherTargetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + + otherTargetCommand.destroy(); + await otherCommands.destroy(); + + await checkTopLevelDocumentTouchSimulation({ enabled: true }); + await checkIframeTouchSimulation({ + enabled: true, + }); + + const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id; + info( + "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled" + ); + + const onBrowserLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + true + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true" + ); + await onBrowserLoaded; + + isnot( + gBrowser.selectedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + await topLevelDocumentMatchesCoarsePointerAtStartup(), + true, + "The touch simulation was enabled in the content page when it loaded after navigating to a new browsing context" + ); + await checkTopLevelDocumentTouchSimulation({ + enabled: true, + }); + + is( + await iframeMatchesCoarsePointerAtStartup(), + true, + "The touch simulation was enabled in the iframe when it loaded after navigating to a new browsing context" + ); + await checkIframeTouchSimulation({ + enabled: true, + }); + + info( + "Check that destroying the commands we enabled the simulation in will disable the simulation" + ); + targetCommand.destroy(); + await commands.destroy(); + + await checkTopLevelDocumentTouchSimulation({ enabled: false }); + await checkIframeTouchSimulation({ + enabled: false, + }); +}); + +function matchesCoarsePointer(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.matchMedia("(pointer: coarse)").matches + ); +} + +function matchesCoarsePointerAtStartup(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.wrappedJSObject.initialMatchesCoarsePointer + ); +} + +async function isTouchEventEmitted(browserOrBrowsingContext) { + const onTimeout = wait(1000).then(() => "TIMEOUT"); + const onTouchEvent = SpecialPowers.spawn( + browserOrBrowsingContext, + [], + async () => { + content.touchStartController = new content.AbortController(); + const el = content.document.querySelector("button"); + + let gotTouchEndEvent = false; + + const promise = new Promise(resolve => { + el.addEventListener( + "touchend", + () => { + gotTouchEndEvent = true; + resolve(); + }, + { + signal: content.touchStartController.signal, + once: true, + } + ); + }); + + // For some reason, it might happen that the event is properly registered and transformed + // in the touch simulator, but not received by the event listener we set up just before. + // So here let's try to "tap" 3 times to give us more chance to catch the event. + for (let i = 0; i < 3; i++) { + if (gotTouchEndEvent) { + break; + } + + // Simulate a "tap" with mousedown and then mouseup. + EventUtils.synthesizeMouseAtCenter( + el, + { type: "mousedown", isSynthesized: false }, + content + ); + + await new Promise(res => content.setTimeout(res, 10)); + EventUtils.synthesizeMouseAtCenter( + el, + { type: "mouseup", isSynthesized: false }, + content + ); + await new Promise(res => content.setTimeout(res, 50)); + } + + return promise; + } + ); + + const result = await Promise.race([onTimeout, onTouchEvent]); + + // Remove the event listener + await SpecialPowers.spawn(browserOrBrowsingContext, [], () => { + content.touchStartController.abort(); + delete content.touchStartController; + }); + + return result !== "TIMEOUT"; +} + +async function checkTopLevelDocumentTouchSimulation({ enabled }) { + is( + await matchesCoarsePointer(gBrowser.selectedBrowser), + enabled, + `The touch simulation is ${ + enabled ? "enabled" : "disabled" + } on the top level document` + ); + + is( + await isTouchEventEmitted(gBrowser.selectedBrowser), + enabled, + `touch events are ${enabled ? "" : "not "}emitted on the top level document` + ); +} + +function topLevelDocumentMatchesCoarsePointerAtStartup() { + return matchesCoarsePointerAtStartup(gBrowser.selectedBrowser); +} + +function getIframeBrowsingContext() { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); +} + +async function checkIframeTouchSimulation({ enabled }) { + const iframeBC = await getIframeBrowsingContext(); + is( + await matchesCoarsePointer(iframeBC), + enabled, + `The touch simulation is ${enabled ? "enabled" : "disabled"} on the iframe` + ); + + is( + await isTouchEventEmitted(iframeBC), + enabled, + `touch events are ${enabled ? "" : "not "}emitted on the iframe` + ); +} + +async function iframeMatchesCoarsePointerAtStartup() { + const iframeBC = await getIframeBrowsingContext(); + return matchesCoarsePointerAtStartup(iframeBC); +} diff --git a/devtools/shared/commands/target-configuration/tests/head.js b/devtools/shared/commands/target-configuration/tests/head.js new file mode 100644 index 0000000000..ce65b3d827 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/head.js @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); diff --git a/devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs b/devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs new file mode 100644 index 0000000000..2b7511c788 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs @@ -0,0 +1,100 @@ +"use strict"; + +function handleRequest(request, response) { + response.setHeader("Content-Type", "html", false); + + // Check the params and set the cross-origin-opener policy headers if needed + const query = new URLSearchParams(request.queryString); + if (query.get("crossOriginIsolated") === "true") { + response.setHeader("Cross-Origin-Opener-Policy", "same-origin", false); + } + + // We always want the iframe to have a different host from the top-level document. + const iframeHost = + request.host === "example.com" ? "example.org" : "example.com"; + const iframeOrigin = `${request.scheme}://${iframeHost}`; + + const IFRAME_HTML = ` + <!doctype html> + <html> + <head> + <meta charset=utf8> + <script> + globalThis.initialMatchesPrefersDarkColorScheme = + window.matchMedia("(prefers-color-scheme: dark)").matches; + globalThis.initialMatchesCoarsePointer = + window.matchMedia("(pointer: coarse)").matches; + globalThis.initialDevicePixelRatio = window.devicePixelRatio; + globalThis.initialUserAgent = navigator.userAgent; + </script> + <style> + html { background: cyan;} + + button { + font-size: 2em; + padding-inline: 1em; + } + + @media (prefers-color-scheme: dark) { + html {background: darkred;} + } + + </style> + </head> + <body> + <h1>Iframe</h1> + <button>Target</button> + </body> + </html>`; + + const HTML = ` + <!doctype html> + <html> + <head> + <meta charset=utf8> + <title>test</title> + <script type="application/javascript"> + "use strict"; + + /* + * Store the result of dark color-scheme match very early in the document loading process + * so we can assert in tests that the simulation starts early enough. + */ + globalThis.initialMatchesPrefersDarkColorScheme = + window.matchMedia("(prefers-color-scheme: dark)").matches; + globalThis.initialMatchesCoarsePointer = + window.matchMedia("(pointer: coarse)").matches; + globalThis.initialDevicePixelRatio = window.devicePixelRatio + globalThis.initialUserAgent = navigator.userAgent; + + + </script> + <style> + iframe { + display: block; + margin-top: 1em; + } + + button { + font-size: 2em; + padding-inline: 1em; + } + + @media (prefers-color-scheme: dark) { + html { + background-color: darkblue; + } + } + </style> + </head> + <body> + <h1>Test color-scheme simulation</h1> + <button>Target</button> + <iframe src='${iframeOrigin}/document-builder.sjs?html=${encodeURI( + IFRAME_HTML + )}'></iframe> + </body> + </html>`; + + response.write(HTML); +} diff --git a/devtools/shared/commands/target/actions/moz.build b/devtools/shared/commands/target/actions/moz.build new file mode 100644 index 0000000000..e9429c1200 --- /dev/null +++ b/devtools/shared/commands/target/actions/moz.build @@ -0,0 +1,7 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "targets.js", +) diff --git a/devtools/shared/commands/target/actions/targets.js b/devtools/shared/commands/target/actions/targets.js new file mode 100644 index 0000000000..577e5fedd3 --- /dev/null +++ b/devtools/shared/commands/target/actions/targets.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ +"use strict"; + +function registerTarget(targetFront) { + return { type: "REGISTER_TARGET", targetFront }; +} + +function unregisterTarget(targetFront) { + return { type: "UNREGISTER_TARGET", targetFront }; +} + +/** + * + * @param {String} targetActorID: The actorID of the target we want to select. + */ +function selectTarget(targetActorID) { + return function ({ dispatch, getState }) { + dispatch({ type: "SELECT_TARGET", targetActorID }); + }; +} + +function refreshTargets() { + return { type: "REFRESH_TARGETS" }; +} + +module.exports = { + registerTarget, + unregisterTarget, + selectTarget, + refreshTargets, +}; diff --git a/devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js new file mode 100644 index 0000000000..e0c5b18d51 --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +class LegacyProcessesWatcher { + constructor(targetCommand, onTargetAvailable, onTargetDestroyed) { + this.targetCommand = targetCommand; + this.rootFront = targetCommand.rootFront; + + this.onTargetAvailable = onTargetAvailable; + this.onTargetDestroyed = onTargetDestroyed; + + this.descriptors = new Set(); + this._processListChanged = this._processListChanged.bind(this); + } + + async _processListChanged() { + if (this.targetCommand.isDestroyed()) { + return; + } + + const processes = await this.rootFront.listProcesses(); + // Process the new list to detect the ones being destroyed + // Force destroyed the descriptor as well as the target + for (const descriptor of this.descriptors) { + if (!processes.includes(descriptor)) { + // Manually call onTargetDestroyed listeners in order to + // ensure calling them *before* destroying the descriptor. + // Otherwise the descriptor will automatically destroy the target + // and may not fire the contentProcessTarget's destroy event. + const target = descriptor.getCachedTarget(); + if (target) { + this.onTargetDestroyed(target); + } + + descriptor.destroy(); + this.descriptors.delete(descriptor); + } + } + + const promises = processes + .filter(descriptor => !this.descriptors.has(descriptor)) + .map(async descriptor => { + // Add the new process descriptors to the local list + this.descriptors.add(descriptor); + const target = await descriptor.getTarget(); + if (!target) { + console.error( + "Wasn't able to retrieve the target for", + descriptor.actorID + ); + return; + } + await this.onTargetAvailable(target); + }); + + await Promise.all(promises); + } + + async listen() { + this.rootFront.on("processListChanged", this._processListChanged); + await this._processListChanged(); + } + + unlisten() { + this.rootFront.off("processListChanged", this._processListChanged); + } +} + +module.exports = LegacyProcessesWatcher; diff --git a/devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js new file mode 100644 index 0000000000..259eaea482 --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js @@ -0,0 +1,302 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + WorkersListener, + // eslint-disable-next-line mozilla/reject-some-requires +} = require("resource://devtools/client/shared/workers-listener.js"); + +const LegacyWorkersWatcher = require("resource://devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js"); + +class LegacyServiceWorkersWatcher extends LegacyWorkersWatcher { + // Holds the current target URL object + #currentTargetURL; + + constructor(targetCommand, onTargetAvailable, onTargetDestroyed, commands) { + super(targetCommand, onTargetAvailable, onTargetDestroyed); + this._registrations = []; + this._processTargets = new Set(); + this.commands = commands; + + // We need to listen for registration changes at least in order to properly + // filter service workers by domain when debugging a local tab. + // + // A WorkerTarget instance has a url property, but it points to the url of + // the script, whereas the url property of the ServiceWorkerRegistration + // points to the URL controlled by the service worker. + // + // Historically we have been matching the service worker registration URL + // to match service workers for local tab tools (app panel & debugger). + // Maybe here we could have some more info on the actual worker. + this._workersListener = new WorkersListener(this.rootFront, { + registrationsOnly: true, + }); + + // Note that this is called much more often than when a registration + // is created or destroyed. WorkersListener notifies of anything that + // potentially impacted workers. + // I use it as a shortcut in this first patch. Listening to rootFront's + // "serviceWorkerRegistrationListChanged" should be enough to be notified + // about registrations. And if we need to also update the + // "debuggerServiceWorkerStatus" from here, then we would have to + // also listen to "registration-changed" one each registration. + this._onRegistrationListChanged = + this._onRegistrationListChanged.bind(this); + this._onDocumentEvent = this._onDocumentEvent.bind(this); + + // Flag used from the parent class to listen to process targets. + // Decision tree is complicated, keep all logic in the parent methods. + this._isServiceWorkerWatcher = true; + } + + /** + * Override from LegacyWorkersWatcher. + * + * We record all valid service worker targets (ie workers that match a service + * worker registration), but we will only notify about the ones which match + * the current domain. + */ + _recordWorkerTarget(workerTarget) { + return !!this._getRegistrationForWorkerTarget(workerTarget); + } + + // Override from LegacyWorkersWatcher. + _supportWorkerTarget(workerTarget) { + if (!workerTarget.isServiceWorker) { + return false; + } + + const registration = this._getRegistrationForWorkerTarget(workerTarget); + return registration && this._isRegistrationValidForTarget(registration); + } + + // Override from LegacyWorkersWatcher. + async listen() { + // Listen to the current target front. + this.target = this.targetCommand.targetFront; + + if (this.targetCommand.descriptorFront.isTabDescriptor) { + this.#currentTargetURL = new URL(this.targetCommand.targetFront.url); + } + + this._workersListener.addListener(this._onRegistrationListChanged); + + // Fetch the registrations before calling listen, since service workers + // might already be available and will need to be compared with the existing + // registrations. + await this._onRegistrationListChanged(); + + if (this.targetCommand.descriptorFront.isTabDescriptor) { + await this.commands.resourceCommand.watchResources( + [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this._onDocumentEvent, + ignoreExistingResources: true, + } + ); + } + + await super.listen(); + } + + // Override from LegacyWorkersWatcher. + unlisten(...args) { + this._workersListener.removeListener(this._onRegistrationListChanged); + + if (this.targetCommand.descriptorFront.isTabDescriptor) { + this.commands.resourceCommand.unwatchResources( + [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this._onDocumentEvent, + } + ); + } + + super.unlisten(...args); + } + + // Override from LegacyWorkersWatcher. + async _onProcessAvailable({ targetFront }) { + if (this.targetCommand.descriptorFront.isTabDescriptor) { + // XXX: This has been ported straight from the current debugger + // implementation. Since pauseMatchingServiceWorkers expects an origin + // to filter matching workers, it only makes sense when we are debugging + // a tab. However in theory, parent process debugging could pause all + // service workers without matching anything. + try { + // To support early breakpoint we need to setup the + // `pauseMatchingServiceWorkers` mechanism in each process. + await targetFront.pauseMatchingServiceWorkers({ + origin: this.#currentTargetURL.origin, + }); + } catch (e) { + if (targetFront.actorID) { + throw e; + } else { + console.warn( + "Process target destroyed while calling pauseMatchingServiceWorkers" + ); + } + } + } + + this._processTargets.add(targetFront); + return super._onProcessAvailable({ targetFront }); + } + + _onProcessDestroyed({ targetFront }) { + this._processTargets.delete(targetFront); + return super._onProcessDestroyed({ targetFront }); + } + + _onDocumentEvent(resources) { + for (const resource of resources) { + if ( + resource.resourceType !== + this.commands.resourceCommand.TYPES.DOCUMENT_EVENT + ) { + continue; + } + + if (resource.name === "will-navigate") { + // We rely on will-navigate as the onTargetAvailable for the top-level frame can + // happen after the onTargetAvailable for processes (handled in _onProcessAvailable), + // where we need the origin we navigate to. + this.#currentTargetURL = new URL(resource.newURI); + continue; + } + + // Note that we rely on "dom-loading" rather than "will-navigate" because the + // destroyed/available callbacks should be triggered after the Debugger + // has cleaned up its reducers, which happens on "will-navigate". + // On the other end, "dom-complete", which is a better mapping of "navigate", is + // happening too late (because of resources being throttled), and would cause failures + // in test (like browser_target_command_service_workers_navigation.js), as the new worker + // target would already be registered at this point, and seen as something that would + // need to be destroyed. + if (resource.name === "dom-loading") { + const allServiceWorkerTargets = this._getAllServiceWorkerTargets(); + + for (const target of allServiceWorkerTargets) { + // Note: we call isTargetRegistered again because calls to + // onTargetDestroyed might have modified the list of registered targets. + const isRegisteredAfter = + this.targetCommand.isTargetRegistered(target); + const isValidTarget = this._supportWorkerTarget(target); + if (isValidTarget && !isRegisteredAfter) { + // If the target is still valid for the current top target, call + // onTargetAvailable as well. + this.onTargetAvailable(target); + } + } + } + } + } + + async _onRegistrationListChanged() { + if (this.targetCommand.isDestroyed()) { + return; + } + + await this._updateRegistrations(); + + // Everything after this point is not strictly necessary for sw support + // in the target list, but it makes the behavior closer to the previous + // listAllWorkers/WorkersListener pair. + const allServiceWorkerTargets = this._getAllServiceWorkerTargets(); + for (const target of allServiceWorkerTargets) { + const hasRegistration = this._getRegistrationForWorkerTarget(target); + if (!hasRegistration) { + // XXX: At this point the worker target is not really destroyed, but + // historically, listAllWorkers* APIs stopped returning worker targets + // if worker registrations are no longer available. + if (this.targetCommand.isTargetRegistered(target)) { + // Only emit onTargetDestroyed if it wasn't already done by + // onNavigate (ie the target is still tracked by TargetCommand) + this.onTargetDestroyed(target); + } + // Here we only care about service workers which no longer match *any* + // registration. The worker will be completely destroyed soon, remove + // it from the legacy worker watcher internal targetsByProcess Maps. + this._removeTargetReferences(target); + } + } + } + + // Delete the provided worker target from the internal targetsByProcess Maps. + _removeTargetReferences(target) { + const allProcessTargets = this._getProcessTargets().filter(t => + this.targetsByProcess.get(t) + ); + + for (const processTarget of allProcessTargets) { + this.targetsByProcess.get(processTarget).delete(target); + } + } + + async _updateRegistrations() { + const { registrations } = + await this.rootFront.listServiceWorkerRegistrations(); + + this._registrations = registrations; + } + + _getRegistrationForWorkerTarget(workerTarget) { + return this._registrations.find(r => { + return ( + r.evaluatingWorker?.id === workerTarget.id || + r.activeWorker?.id === workerTarget.id || + r.installingWorker?.id === workerTarget.id || + r.waitingWorker?.id === workerTarget.id + ); + }); + } + + _getProcessTargets() { + return [...this._processTargets]; + } + + // Flatten all service worker targets in all processes. + _getAllServiceWorkerTargets() { + const allProcessTargets = this._getProcessTargets().filter(target => + this.targetsByProcess.get(target) + ); + + const serviceWorkerTargets = []; + for (const target of allProcessTargets) { + serviceWorkerTargets.push(...this.targetsByProcess.get(target)); + } + return serviceWorkerTargets; + } + + // Check if the registration is relevant for the current target, ie + // corresponds to the same domain. + _isRegistrationValidForTarget(registration) { + if (this.targetCommand.descriptorFront.isBrowserProcessDescriptor) { + // All registrations are valid for main process debugging. + return true; + } + + if (!this.targetCommand.descriptorFront.isTabDescriptor) { + // No support for service worker targets outside of main process & + // tab debugging. + return false; + } + + // For local tabs, we match ServiceWorkerRegistrations and the target + // if they share the same hostname for their "url" properties. + const targetDomain = this.#currentTargetURL.hostname; + try { + const registrationDomain = new URL(registration.url).hostname; + return registrationDomain === targetDomain; + } catch (e) { + // XXX: Some registrations have an empty URL. + return false; + } + } +} + +module.exports = LegacyServiceWorkersWatcher; diff --git a/devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js new file mode 100644 index 0000000000..b248e6aef7 --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const LegacyWorkersWatcher = require("resource://devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js"); + +class LegacySharedWorkersWatcher extends LegacyWorkersWatcher { + // Flag used from the parent class to listen to process targets. + // Decision tree is complicated, keep all logic in the parent methods. + _isSharedWorkerWatcher = true; + + _supportWorkerTarget(workerTarget) { + return workerTarget.isSharedWorker; + } +} + +module.exports = LegacySharedWorkersWatcher; diff --git a/devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js new file mode 100644 index 0000000000..d359d5375e --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js @@ -0,0 +1,234 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const LegacyProcessesWatcher = require("resource://devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js"); + +class LegacyWorkersWatcher { + constructor(targetCommand, onTargetAvailable, onTargetDestroyed) { + this.targetCommand = targetCommand; + this.rootFront = targetCommand.rootFront; + + this.onTargetAvailable = onTargetAvailable; + this.onTargetDestroyed = onTargetDestroyed; + + this.targetsByProcess = new WeakMap(); + this.targetsListeners = new WeakMap(); + + this._onProcessAvailable = this._onProcessAvailable.bind(this); + this._onProcessDestroyed = this._onProcessDestroyed.bind(this); + } + + async _onProcessAvailable({ targetFront }) { + this.targetsByProcess.set(targetFront, new Set()); + // Listen for worker which will be created later + const listener = this._workerListChanged.bind(this, targetFront); + this.targetsListeners.set(targetFront, listener); + + // If this is the browser toolbox, we have to listen from the RootFront + // (see comment in _workerListChanged) + const front = targetFront.isParentProcess ? this.rootFront : targetFront; + front.on("workerListChanged", listener); + + // We also need to process the already existing workers + await this._workerListChanged(targetFront); + } + + async _onProcessDestroyed({ targetFront }) { + const existingTargets = this.targetsByProcess.get(targetFront); + + // Process the new list to detect the ones being destroyed + // Force destroying the targets + for (const target of existingTargets) { + this.onTargetDestroyed(target); + + target.destroy(); + existingTargets.delete(target); + } + this.targetsByProcess.delete(targetFront); + this.targetsListeners.delete(targetFront); + } + + _supportWorkerTarget(workerTarget) { + // subprocess workers are ignored because they take several seconds to + // attach to when opening the browser toolbox. See bug 1594597. + // When attaching we get the following error: + // JavaScript error: resource://devtools/server/startup/worker.js, + // line 37: NetworkError: WorkerDebuggerGlobalScope.loadSubScript: Failed to load worker script at resource://devtools/shared/worker/loader.js (nsresult = 0x805e0006) + return ( + workerTarget.isDedicatedWorker && + !/resource:\/\/gre\/modules\/subprocess\/subprocess_.*\.worker\.js/.test( + workerTarget.url + ) + ); + } + + async _workerListChanged(targetFront) { + // If we're in the Browser Toolbox, query workers from the Root Front instead of the + // ParentProcessTarget as the ParentProcess Target filters out the workers to only + // show the one from the top level window, whereas we expect the one from all the + // windows, and also the window-less ones. + // TODO: For Content Toolbox, expose SW of the page, maybe optionally? + const front = targetFront.isParentProcess ? this.rootFront : targetFront; + if (!front || front.isDestroyed() || this.targetCommand.isDestroyed()) { + return; + } + + let workers; + try { + ({ workers } = await front.listWorkers()); + } catch (e) { + // Workers may be added/removed at anytime so that listWorkers request + // can be spawn during a toolbox destroy sequence and easily fail + if (front.isDestroyed()) { + return; + } + throw e; + } + + // Fetch the list of already existing worker targets for this process target front. + const existingTargets = this.targetsByProcess.get(targetFront); + if (!existingTargets) { + // unlisten was called while processing the workerListChanged callback. + return; + } + + // Process the new list to detect the ones being destroyed + // Force destroying the targets + for (const target of existingTargets) { + if (!workers.includes(target)) { + this.onTargetDestroyed(target); + + target.destroy(); + existingTargets.delete(target); + } + } + + const promises = workers.map(workerTarget => + this._processNewWorkerTarget(workerTarget, existingTargets) + ); + await Promise.all(promises); + } + + // This is overloaded for Service Workers, which records all SW targets, + // but only notify about a subset of them. + _recordWorkerTarget(workerTarget) { + return this._supportWorkerTarget(workerTarget); + } + + async _processNewWorkerTarget(workerTarget, existingTargets) { + if ( + !this._recordWorkerTarget(workerTarget) || + existingTargets.has(workerTarget) || + this.targetCommand.isDestroyed() + ) { + return; + } + + // Add the new worker targets to the local list + existingTargets.add(workerTarget); + + if (this._supportWorkerTarget(workerTarget)) { + await this.onTargetAvailable(workerTarget); + } + } + + async listen() { + // Listen to the current target front. + this.target = this.targetCommand.targetFront; + + if (this.target.isParentProcess) { + await this.targetCommand.watchTargets({ + types: [this.targetCommand.TYPES.PROCESS], + onAvailable: this._onProcessAvailable, + onDestroyed: this._onProcessDestroyed, + }); + + // The ParentProcessTarget front is considered to be a FRAME instead of a PROCESS. + // So process it manually here. + await this._onProcessAvailable({ targetFront: this.target }); + return; + } + + if (this._isSharedWorkerWatcher) { + // Here we're not in the browser toolbox, and SharedWorker targets are not supported + // in regular toolbox (See Bug 1607778) + return; + } + + if (this._isServiceWorkerWatcher) { + this._legacyProcessesWatcher = new LegacyProcessesWatcher( + this.targetCommand, + async targetFront => { + // Service workers only live in content processes. + if (!targetFront.isParentProcess) { + await this._onProcessAvailable({ targetFront }); + } + }, + targetFront => { + if (!targetFront.isParentProcess) { + this._onProcessDestroyed({ targetFront }); + } + } + ); + await this._legacyProcessesWatcher.listen(); + return; + } + + // Here, we're handling Dedicated Workers in content toolbox. + this.targetsByProcess.set( + this.target, + this.targetsByProcess.get(this.target) || new Set() + ); + this._workerListChangedListener = this._workerListChanged.bind( + this, + this.target + ); + this.target.on("workerListChanged", this._workerListChangedListener); + await this._workerListChanged(this.target); + } + + _getProcessTargets() { + return this.targetCommand.getAllTargets([this.targetCommand.TYPES.PROCESS]); + } + + unlisten({ isTargetSwitching } = {}) { + // Stop listening for new process targets. + if (this.target.isParentProcess) { + this.targetCommand.unwatchTargets({ + types: [this.targetCommand.TYPES.PROCESS], + onAvailable: this._onProcessAvailable, + onDestroyed: this._onProcessDestroyed, + }); + } else if (this._isServiceWorkerWatcher) { + this._legacyProcessesWatcher.unlisten(); + } + + // Cleanup the targetsByProcess/targetsListeners maps, and unsubscribe from + // all targetFronts. Process target fronts are either stored locally when + // watching service workers for the content toolbox, or can be retrieved via + // the TargetCommand API otherwise (see _getProcessTargets implementations). + if (this.target.isParentProcess || this._isServiceWorkerWatcher) { + for (const targetFront of this._getProcessTargets()) { + const listener = this.targetsListeners.get(targetFront); + targetFront.off("workerListChanged", listener); + + // When unlisten is called from a target switch or when we observe service workers targets + // we don't want to remove the targets from targetsByProcess + if (!isTargetSwitching || !this._isServiceWorkerWatcher) { + this.targetsByProcess.delete(targetFront); + } + this.targetsListeners.delete(targetFront); + } + } else { + this.target.off("workerListChanged", this._workerListChangedListener); + delete this._workerListChangedListener; + this.targetsByProcess.delete(this.target); + this.targetsListeners.delete(this.target); + } + } +} + +module.exports = LegacyWorkersWatcher; diff --git a/devtools/shared/commands/target/legacy-target-watchers/moz.build b/devtools/shared/commands/target/legacy-target-watchers/moz.build new file mode 100644 index 0000000000..60fdd7ec22 --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/moz.build @@ -0,0 +1,10 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "legacy-processes-watcher.js", + "legacy-serviceworkers-watcher.js", + "legacy-sharedworkers-watcher.js", + "legacy-workers-watcher.js", +) diff --git a/devtools/shared/commands/target/moz.build b/devtools/shared/commands/target/moz.build new file mode 100644 index 0000000000..811fc180f0 --- /dev/null +++ b/devtools/shared/commands/target/moz.build @@ -0,0 +1,17 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "actions", + "legacy-target-watchers", + "reducers", + "selectors", +] + +DevToolsModules( + "target-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/target/reducers/moz.build b/devtools/shared/commands/target/reducers/moz.build new file mode 100644 index 0000000000..e9429c1200 --- /dev/null +++ b/devtools/shared/commands/target/reducers/moz.build @@ -0,0 +1,7 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "targets.js", +) diff --git a/devtools/shared/commands/target/reducers/targets.js b/devtools/shared/commands/target/reducers/targets.js new file mode 100644 index 0000000000..2e93ddd7f0 --- /dev/null +++ b/devtools/shared/commands/target/reducers/targets.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ +"use strict"; + +const initialReducerState = { + // Array of targetFront + targets: [], + // The selected targetFront instance + selected: null, + // timestamp of the last time a target was updated (i.e. url/title was updated). + // This is used by the EvaluationContextSelector component to re-render the list of + // targets when the list itself did not change (no addition/removal) + lastTargetRefresh: Date.now(), +}; + +function update(state = initialReducerState, action) { + switch (action.type) { + case "SELECT_TARGET": { + const { targetActorID } = action; + + if (state.selected?.actorID === targetActorID) { + return state; + } + + const selectedTarget = state.targets.find( + target => target.actorID === targetActorID + ); + + // It's possible that the target reducer is missing a target + // e.g. workers, remote iframes, etc. (Bug 1594754) + if (!selectedTarget) { + return state; + } + + return { ...state, selected: selectedTarget }; + } + + case "REGISTER_TARGET": { + return { + ...state, + targets: [...state.targets, action.targetFront], + }; + } + + case "REFRESH_TARGETS": { + // The data _in_ targetFront was updated, so we only need to mutate the state, + // while keeping the same values. + return { + ...state, + lastTargetRefresh: Date.now(), + }; + } + + case "UNREGISTER_TARGET": { + const targets = state.targets.filter( + target => target !== action.targetFront + ); + + let { selected } = state; + if (selected === action.targetFront) { + selected = null; + } + + return { ...state, targets, selected }; + } + } + return state; +} +module.exports = update; diff --git a/devtools/shared/commands/target/selectors/moz.build b/devtools/shared/commands/target/selectors/moz.build new file mode 100644 index 0000000000..e9429c1200 --- /dev/null +++ b/devtools/shared/commands/target/selectors/moz.build @@ -0,0 +1,7 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "targets.js", +) diff --git a/devtools/shared/commands/target/selectors/targets.js b/devtools/shared/commands/target/selectors/targets.js new file mode 100644 index 0000000000..95da81bbba --- /dev/null +++ b/devtools/shared/commands/target/selectors/targets.js @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ +"use strict"; + +function getToolboxTargets(state) { + return state.targets; +} + +function getSelectedTarget(state) { + return state.selected; +} + +function getLastTargetRefresh(state) { + return state.lastTargetRefresh; +} + +exports.getToolboxTargets = getToolboxTargets; +exports.getSelectedTarget = getSelectedTarget; +exports.getLastTargetRefresh = getLastTargetRefresh; diff --git a/devtools/shared/commands/target/target-command.js b/devtools/shared/commands/target/target-command.js new file mode 100644 index 0000000000..81b791f724 --- /dev/null +++ b/devtools/shared/commands/target/target-command.js @@ -0,0 +1,1167 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +const BROWSERTOOLBOX_SCOPE_PREF = "devtools.browsertoolbox.scope"; +// Possible values of the previous pref: +const BROWSERTOOLBOX_SCOPE_EVERYTHING = "everything"; +const BROWSERTOOLBOX_SCOPE_PARENTPROCESS = "parent-process"; + +// eslint-disable-next-line mozilla/reject-some-requires +const createStore = require("resource://devtools/client/shared/redux/create-store.js"); +const reducer = require("resource://devtools/shared/commands/target/reducers/targets.js"); + +loader.lazyRequireGetter( + this, + ["refreshTargets", "registerTarget", "unregisterTarget"], + "resource://devtools/shared/commands/target/actions/targets.js", + true +); + +class TargetCommand extends EventEmitter { + #selectedTargetFront; + /** + * This class helps managing, iterating over and listening for Targets. + * + * It exposes: + * - the top level target, typically the main process target for the browser toolbox + * or the browsing context target for a regular web toolbox + * - target of remoted iframe, in case Fission is enabled and some <iframe> + * are running in a distinct process + * - target switching. If the top level target changes for a new one, + * all the targets are going to be declared as destroyed and the new ones + * will be notified to the user of this API. + * + * @fires target-tread-wrong-order-on-resume : An event that is emitted when resuming + * the thread throws with the "wrongOrder" error. + * + * @param {DescriptorFront} descriptorFront + * The context to inspector identified by this descriptor. + * @param {WatcherFront} watcherFront + * If available, a reference to the related Watcher Front. + * @param {Object} commands + * The commands object with all interfaces defined from devtools/shared/commands/ + */ + constructor({ descriptorFront, watcherFront, commands }) { + super(); + + this.commands = commands; + this.descriptorFront = descriptorFront; + this.watcherFront = watcherFront; + this.rootFront = descriptorFront.client.mainRoot; + + this.store = createStore(reducer); + // Name of the store used when calling createProvider. + this.storeId = "target-store"; + + this._updateBrowserToolboxScope = + this._updateBrowserToolboxScope.bind(this); + + Services.prefs.addObserver( + BROWSERTOOLBOX_SCOPE_PREF, + this._updateBrowserToolboxScope + ); + // Until Watcher actor notify about new top level target when navigating to another process + // we have to manually switch to a new target from the client side + this.onLocalTabRemotenessChange = + this.onLocalTabRemotenessChange.bind(this); + if (this.descriptorFront.isTabDescriptor) { + this.descriptorFront.on( + "remoteness-change", + this.onLocalTabRemotenessChange + ); + } + + if (this.isServerTargetSwitchingEnabled()) { + // XXX: Will only be used for local tab server side target switching if + // the first target is generated from the server. + this._onFirstTarget = new Promise(r => (this._resolveOnFirstTarget = r)); + } + + // Reports if we have at least one listener for the given target type + this._listenersStarted = new Set(); + + // List of all the target fronts + this._targets = new Set(); + // {Map<Function, Set<targetFront>>} A Map keyed by `onAvailable` function passed to + // `watchTargets`, whose initial value is a Set of the existing target fronts at the + // time watchTargets is called. + this._pendingWatchTargetInitialization = new Map(); + + // Listeners for target creation, destruction and selection + this._createListeners = new EventEmitter(); + this._destroyListeners = new EventEmitter(); + this._selectListeners = new EventEmitter(); + + this._onTargetAvailable = this._onTargetAvailable.bind(this); + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + this._onTargetSelected = this._onTargetSelected.bind(this); + // Bug 1675763: Watcher actor is not available in all situations yet. + if (this.watcherFront) { + this.watcherFront.on("target-available", this._onTargetAvailable); + this.watcherFront.on("target-destroyed", this._onTargetDestroyed); + } + + this.legacyImplementation = {}; + + // Public flag to allow listening for workers even if the fission pref is off + // This allows listening for workers in the content toolbox outside of fission contexts + // For now, this is only toggled by tests. + this.listenForWorkers = + this.rootFront.traits.workerConsoleApiMessagesDispatchedToMainThread === + false; + this.listenForServiceWorkers = false; + + // Tells us if we received the first top level target. + // If target switching is done on: + // * client side, this is done from startListening => _createFirstTarget + // and pull from the Descriptor front. + // * server side, this is also done from startListening, + // but we wait for the watcher actor to notify us about it + // via target-available-form avent. + this._gotFirstTopLevelTarget = false; + this._onResourceAvailable = this._onResourceAvailable.bind(this); + } + + get selectedTargetFront() { + return this.#selectedTargetFront || this.targetFront; + } + + /** + * Called fired when BROWSERTOOLBOX_SCOPE_PREF pref changes. + * This will enable/disable the full multiprocess debugging. + * When enabled we will watch for content process targets and debug all the processes. + * When disabled we will only watch for FRAME and WORKER and restrict ourself to parent process resources. + */ + _updateBrowserToolboxScope() { + const browserToolboxScope = Services.prefs.getCharPref( + BROWSERTOOLBOX_SCOPE_PREF + ); + if (browserToolboxScope == BROWSERTOOLBOX_SCOPE_EVERYTHING) { + // Force listening to new additional target types + this.startListening(); + } else if (browserToolboxScope == BROWSERTOOLBOX_SCOPE_PARENTPROCESS) { + const disabledTargetTypes = [ + TargetCommand.TYPES.FRAME, + TargetCommand.TYPES.PROCESS, + ]; + // Force unwatching for additional targets types + // (we keep listening to workers) + // The related targets will be destroyed by the server + // and reported as destroyed to the frontend. + for (const type of disabledTargetTypes) { + this.stopListeningForType(type, { + isTargetSwitching: false, + isModeSwitching: true, + }); + } + } + } + + // Called whenever a new Target front is available. + // Either because a target was already available as we started calling startListening + // or if it has just been created + async _onTargetAvailable(targetFront) { + // We put the `commands` on the targetFront so it can be retrieved from any front easily. + // Without this, protocol.js fronts won't have any easy access to it. + // Ideally, Fronts would all be migrated to commands and we would no longer need this hack. + targetFront.commands = this.commands; + + // If the new target is a top level target, we are target switching. + // Target-switching is only triggered for "local-tab" browsing-context + // targets which should always have the topLevelTarget flag initialized + // on the server. + const isTargetSwitching = targetFront.isTopLevel; + const isFirstTarget = + targetFront.isTopLevel && !this._gotFirstTopLevelTarget; + + if (this._targets.has(targetFront)) { + // The top level target front can be reported via listProcesses in the + // case of the BrowserToolbox. For any other target, log an error if it is + // already registered. + if (targetFront != this.targetFront) { + console.error( + "Target is already registered in the TargetCommand", + targetFront.actorID + ); + } + return; + } + + if (this.isDestroyed() || targetFront.isDestroyedOrBeingDestroyed()) { + return; + } + + // Handle top level target switching + // Note that, for now, `_onTargetAvailable` isn't called for the *initial* top level target. + // i.e. the one that is passed to TargetCommand constructor. + if (targetFront.isTopLevel) { + // First report that all existing targets are destroyed + if (!isFirstTarget) { + this._destroyExistingTargetsOnTargetSwitching(); + } + + // Update the reference to the memoized top level target + this.targetFront = targetFront; + this.descriptorFront.setTarget(targetFront); + this.#selectedTargetFront = null; + + if (isFirstTarget && this.isServerTargetSwitchingEnabled()) { + this._gotFirstTopLevelTarget = true; + this._resolveOnFirstTarget(); + } + } + + // Map the descriptor typeName to a target type. + const targetType = this.getTargetType(targetFront); + targetFront.setTargetType(targetType); + + this._targets.add(targetFront); + try { + await targetFront.attachAndInitThread(this); + } catch (e) { + console.error("Error when attaching target:", e); + this._targets.delete(targetFront); + return; + } + + for (const targetFrontsSet of this._pendingWatchTargetInitialization.values()) { + targetFrontsSet.delete(targetFront); + } + + if (this.isDestroyed() || targetFront.isDestroyedOrBeingDestroyed()) { + return; + } + + this.store.dispatch(registerTarget(targetFront)); + + // Then, once the target is attached, notify the target front creation listeners + await this._createListeners.emitAsync(targetType, { + targetFront, + isTargetSwitching, + }); + + // Re-register the listeners as the top level target changed + // and some targets are fetched from it + if (targetFront.isTopLevel && !isFirstTarget) { + await this.startListening({ isTargetSwitching: true }); + } + + // These two events are used by tests using the production codepath (i.e. disabling flags.testing) + // To be consumed by tests triggering frame navigations, spawning workers... + this.emit("processed-available-target", targetFront); + + if (isTargetSwitching) { + this.emit("switched-target", targetFront); + } + } + + _destroyExistingTargetsOnTargetSwitching() { + const destroyedTargets = []; + for (const target of this._targets) { + // We only consider the top level target to be switched + const isDestroyedTargetSwitching = target == this.targetFront; + const isServiceWorker = target.targetType === this.TYPES.SERVICE_WORKER; + const isPopup = target.targetForm.isPopup; + + // Never destroy the popup targets when the top level target is destroyed + // as the popup follow a different lifecycle. + // Also avoid destroying service worker targets for similar reason. + if (!isPopup && !isServiceWorker) { + this._onTargetDestroyed(target, { + isTargetSwitching: isDestroyedTargetSwitching, + // Do not destroy service worker front as we may want to keep using it. + shouldDestroyTargetFront: !isServiceWorker, + }); + destroyedTargets.push(target); + } + } + + // Stop listening to legacy listeners as we now have to listen + // on the new target. + this.stopListening({ isTargetSwitching: true }); + + // Remove destroyed target from the cached target list. We don't simply clear the + // Map as SW targets might not have been destroyed. + for (const target of destroyedTargets) { + this._targets.delete(target); + } + } + + /** + * Function fired everytime a target is destroyed. + * + * This is called either: + * - via target-destroyed event fired by the WatcherFront, + * event which is a simple translation of the target-destroyed-form emitted by the WatcherActor. + * Watcher Actor emits this is various condition when the debugged target is meant to be destroyed: + * - the related target context is destroyed (tab closed, worker shut down, content process destroyed, ...), + * - when the DevToolsServerConnection used on the server side to communicate to the client is closed. + + * - by TargetCommand._onTargetAvailable, when a top level target switching happens and all previously + * registered target fronts should be destroyed. + + * - by the legacy Targets listeners, calling this method directly. + * This usecase is meant to be removed someday when all target targets are supported by the Watcher. + * (bug 1687459) + * + * @param {TargetFront} targetFront + * The target that just got destroyed. + * @param {Object} options + * @param {Boolean} [options.isTargetSwitching] + * To be set to true when this is about the top level target which is being replaced + * by a new one. + * The passed target should be still the one store in TargetCommand.targetFront + * and will be replaced via a call to onTargetAvailable with a new target front. + * @param {Boolean} [options.isModeSwitching] + * To be set to true when the target was destroyed was called as the result of a + * change to the devtools.browsertoolbox.scope pref. + * @param {Boolean} [options.shouldDestroyTargetFront] + * By default, the passed target front will be destroyed. But in some cases like + * legacy listeners for service workers we want to keep the front alive. + */ + _onTargetDestroyed( + targetFront, + { + isModeSwitching = false, + isTargetSwitching = false, + shouldDestroyTargetFront = true, + } = {} + ) { + // The watcher actor may notify us about the destruction of the top level target. + // But second argument to this method, isTargetSwitching is only passed from the frontend. + // So automatically toggle the isTargetSwitching flag for server side destructions + // only if that's about the existing top level target. + if (targetFront == this.targetFront) { + isTargetSwitching = true; + } + this._destroyListeners.emit(targetFront.targetType, { + targetFront, + isTargetSwitching, + isModeSwitching, + }); + this._targets.delete(targetFront); + + this.store.dispatch(unregisterTarget(targetFront)); + + // If the destroyed target was the selected one, we need to do some cleanup + if (this.#selectedTargetFront == targetFront) { + // If we're doing a targetSwitch, simply nullify #selectedTargetFront + if (isTargetSwitching) { + this.#selectedTargetFront = null; + } else { + // Otherwise we want to select the top level target + this.selectTarget(this.targetFront); + } + } + + if (shouldDestroyTargetFront) { + // When calling targetFront.destroy(), we will first call TargetFrontMixin.destroy, + // which will try to call `detach` RDP method. + // Unfortunately, this request will never complete in some cases like bfcache navigations. + // Because of that, the target front will never be completely destroy as it will prevent + // calling super.destroy and Front.destroy. + // Workaround that by manually calling Front class destroy method: + targetFront.baseFrontClassDestroy(); + + targetFront.destroy(); + + // Delete the attribute we set from _onTargetAvailable so that we avoid leaking commands + // if any target front is leaked. + delete targetFront.commands; + } + } + + /** + * + * @param {TargetFront} targetFront + */ + async _onTargetSelected(targetFront) { + if (this.#selectedTargetFront == targetFront) { + // Target is already selected, we can bail out. + return; + } + + this.#selectedTargetFront = targetFront; + const targetType = this.getTargetType(targetFront); + await this._selectListeners.emitAsync(targetType, { + targetFront, + }); + } + + _setListening(type, value) { + if (value) { + this._listenersStarted.add(type); + } else { + this._listenersStarted.delete(type); + } + } + + _isListening(type) { + return this._listenersStarted.has(type); + } + + /** + * Check if the watcher is currently supported. + * + * When no typeOrTrait is provided, we will only check that the watcher is + * available. + * + * When a typeOrTrait is provided, we will check for an explicit trait on the + * watcherFront that indicates either that: + * - a target type is supported + * - or that a custom trait is true + * + * @param {String} [targetTypeOrTrait] + * Optional target type or trait. + * @return {Boolean} true if the watcher is available and supports the + * optional targetTypeOrTrait + */ + hasTargetWatcherSupport(targetTypeOrTrait) { + if (targetTypeOrTrait) { + // Target types are also exposed as traits, where resource types are + // exposed under traits.resources (cf hasResourceWatcherSupport + // implementation). + return !!this.watcherFront?.traits[targetTypeOrTrait]; + } + + return !!this.watcherFront; + } + + /** + * Start listening for targets from the server + * + * Interact with the actors in order to start listening for new types of targets. + * This will fire the _onTargetAvailable function for all already-existing targets, + * as well as the next one to be created. It will also call _onTargetDestroyed + * everytime a target is reported as destroyed by the actors. + * By the time this function resolves, all the already-existing targets will be + * reported to _onTargetAvailable. + * + * @param Object options + * @param Boolean options.isTargetSwitching + * Set to true when this is called while a target switching happens. In such case, + * we won't register listener set on the Watcher Actor, but still register listeners + * set via Legacy Listeners. + */ + async startListening({ isTargetSwitching = false } = {}) { + // The first time we call this method, we pull the current top level target from the descriptor + if ( + !this.isServerTargetSwitchingEnabled() && + !this._gotFirstTopLevelTarget + ) { + await this._createFirstTarget(); + } + + // If no pref are set to true, nor is listenForWorkers set to true, + // we won't listen for any additional target. Only the top level target + // will be managed. We may still do target-switching. + const types = this._computeTargetTypes(); + + for (const type of types) { + if (this._isListening(type)) { + continue; + } + this._setListening(type, true); + + // Only a few top level targets support the watcher actor at the moment (see WatcherActor + // traits in the _form method). Bug 1675763 tracks watcher actor support for all targets. + if (this.hasTargetWatcherSupport(type)) { + // When we switch to a new top level target, we don't have to stop and restart + // Watcher listener as it is independant from the top level target. + // This isn't the case for some Legacy Listeners, which fetch targets from the top level target + if (!isTargetSwitching) { + await this.watcherFront.watchTargets(type); + } + } else if (LegacyTargetWatchers[type]) { + // Instantiate the legacy listener only once for each TargetCommand, and reuse it if we stop and restart listening + if (!this.legacyImplementation[type]) { + this.legacyImplementation[type] = new LegacyTargetWatchers[type]( + this, + this._onTargetAvailable, + this._onTargetDestroyed, + this.commands + ); + } + await this.legacyImplementation[type].listen(); + } else { + throw new Error(`Unsupported target type '${type}'`); + } + } + + if (!this._watchingDocumentEvent && !this.isDestroyed()) { + // We want to watch DOCUMENT_EVENT in order to update the url and title of target fronts, + // as the initial value that is set in them might be erroneous (if the target was + // created so early that the document url is still pointing to about:blank and the + // html hasn't be parsed yet, so we can't know the <title> content). + + this._watchingDocumentEvent = true; + await this.commands.resourceCommand.watchResources( + [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this._onResourceAvailable, + } + ); + } + + if (this.isServerTargetSwitchingEnabled()) { + await this._onFirstTarget; + } + } + + async _createFirstTarget() { + // Note that this is a public attribute, used outside of this class + // and helps knowing what is the current top level target we debug. + this.targetFront = await this.descriptorFront.getTarget(); + this.targetFront.setTargetType(this.getTargetType(this.targetFront)); + this.targetFront.setIsTopLevel(true); + this._gotFirstTopLevelTarget = true; + + // See _onTargetAvailable. As this target isn't going through that method + // we have to replicate doing that here. + this.targetFront.commands = this.commands; + + // Add the top-level target to the list of targets. + this._targets.add(this.targetFront); + this.store.dispatch(registerTarget(this.targetFront)); + } + + _computeTargetTypes() { + let types = []; + + // We also check for watcher support as some xpcshell tests uses legacy APIs and don't support frames. + if ( + this.descriptorFront.isTabDescriptor && + this.hasTargetWatcherSupport(TargetCommand.TYPES.FRAME) + ) { + types = [TargetCommand.TYPES.FRAME]; + } else if (this.descriptorFront.isBrowserProcessDescriptor) { + const browserToolboxScope = Services.prefs.getCharPref( + BROWSERTOOLBOX_SCOPE_PREF + ); + if (browserToolboxScope == BROWSERTOOLBOX_SCOPE_EVERYTHING) { + types = TargetCommand.ALL_TYPES; + } + } + if (this.listenForWorkers && !types.includes(TargetCommand.TYPES.WORKER)) { + types.push(TargetCommand.TYPES.WORKER); + } + if ( + this.listenForWorkers && + !types.includes(TargetCommand.TYPES.SHARED_WORKER) + ) { + types.push(TargetCommand.TYPES.SHARED_WORKER); + } + if ( + this.listenForServiceWorkers && + !types.includes(TargetCommand.TYPES.SERVICE_WORKER) + ) { + types.push(TargetCommand.TYPES.SERVICE_WORKER); + } + + return types; + } + + /** + * Stop listening for targets from the server + * + * @param Object options + * @param Boolean options.isTargetSwitching + * Set to true when this is called while a target switching happens. In such case, + * we won't unregister listener set on the Watcher Actor, but still unregister + * listeners set via Legacy Listeners. + */ + stopListening({ isTargetSwitching = false } = {}) { + // As DOCUMENT_EVENT isn't using legacy listener, + // there is no need to stop and restart it in case of target switching. + if (this._watchingDocumentEvent && !isTargetSwitching) { + this.commands.resourceCommand.unwatchResources( + [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this._onResourceAvailable, + } + ); + this._watchingDocumentEvent = false; + } + + for (const type of TargetCommand.ALL_TYPES) { + this.stopListeningForType(type, { isTargetSwitching }); + } + } + + /** + * Stop listening for targets of a given type from the server + * + * @param String type + * target type we want to stop listening for + * @param Object options + * @param Boolean options.isTargetSwitching + * Set to true when this is called while a target switching happens. In such case, + * we won't unregister listener set on the Watcher Actor, but still unregister + * listeners set via Legacy Listeners. + * @param Boolean options.isModeSwitching + * Set to true when this is called as the result of a change to the + * devtools.browsertoolbox.scope pref. + */ + stopListeningForType(type, { isTargetSwitching, isModeSwitching }) { + if (!this._isListening(type)) { + return; + } + this._setListening(type, false); + + // Only a few top level targets support the watcher actor at the moment (see WatcherActor + // traits in the _form method). Bug 1675763 tracks watcher actor support for all targets. + if (this.hasTargetWatcherSupport(type)) { + // When we switch to a new top level target, we don't have to stop and restart + // Watcher listener as it is independant from the top level target. + // This isn't the case for some Legacy Listeners, which fetch targets from the top level target + // Also, TargetCommand.destroy may be called after the client is closed. + // So avoid calling the RDP method in that situation. + if (!isTargetSwitching && !this.watcherFront.isDestroyed()) { + this.watcherFront.unwatchTargets(type, { isModeSwitching }); + } + } else if (this.legacyImplementation[type]) { + this.legacyImplementation[type].unlisten({ + isTargetSwitching, + isModeSwitching, + }); + } else { + throw new Error(`Unsupported target type '${type}'`); + } + } + + getTargetType(target) { + const { typeName } = target; + if (typeName == "windowGlobalTarget") { + return TargetCommand.TYPES.FRAME; + } + + if ( + typeName == "contentProcessTarget" || + typeName == "parentProcessTarget" + ) { + return TargetCommand.TYPES.PROCESS; + } + + if (typeName == "workerDescriptor" || typeName == "workerTarget") { + if (target.isSharedWorker) { + return TargetCommand.TYPES.SHARED_WORKER; + } + + if (target.isServiceWorker) { + return TargetCommand.TYPES.SERVICE_WORKER; + } + + return TargetCommand.TYPES.WORKER; + } + + throw new Error("Unsupported target typeName: " + typeName); + } + + _matchTargetType(type, target) { + return type === target.targetType; + } + + _onResourceAvailable(resources) { + for (const resource of resources) { + if ( + resource.resourceType === + this.commands.resourceCommand.TYPES.DOCUMENT_EVENT + ) { + const { targetFront } = resource; + if (resource.title !== undefined && targetFront?.setTitle) { + targetFront.setTitle(resource.title); + } + if (resource.url !== undefined && targetFront?.setUrl) { + targetFront.setUrl(resource.url); + } + if ( + !resource.isFrameSwitching && + // `url` is set on the targetFront when we receive dom-loading, and `title` when + // `dom-interactive` is received. Here we're only updating the window title in + // the "newer" event. + resource.name === "dom-interactive" + ) { + // We just updated the targetFront title and url, force a refresh + // so that the EvaluationContext selector update them. + this.store.dispatch(refreshTargets()); + } + } + } + } + + /** + * Listen for the creation and/or destruction of target fronts matching one of the provided types. + * + * @param {Object} options + * @param {Array<String>} options.types + * The type of target to listen for. Constant of TargetCommand.TYPES. + * @param {Function} options.onAvailable + * Mandatory callback fired when a target has been just created or was already available. + * The function is called with a single object argument containing the following properties: + * - {TargetFront} targetFront: The target Front + * - {Boolean} isTargetSwitching: Is this target relates to a navigation and + * this replaced a previously available target, this flag will be true + * @param {Function} options.onDestroyed + * Optional callback fired in case of target front destruction. + * The function is called with the same arguments than onAvailable. + * @param {Function} options.onSelected + * Optional callback fired when a given target is selected from the iframe picker + * The function is called with a single object argument containing the following properties: + * - {TargetFront} targetFront: The target Front + */ + async watchTargets(options = {}) { + const availableOptions = [ + "types", + "onAvailable", + "onDestroyed", + "onSelected", + ]; + const unsupportedKeys = Object.keys(options).filter( + key => !availableOptions.includes(key) + ); + if (unsupportedKeys.length) { + throw new Error( + `TargetCommand.watchTargets does not expect the following options: ${unsupportedKeys.join( + ", " + )}` + ); + } + + const { types, onAvailable, onDestroyed, onSelected } = options; + if (typeof onAvailable != "function") { + throw new Error( + "TargetCommand.watchTargets expects a function for the onAvailable option" + ); + } + + for (const type of types) { + if (!this._isValidTargetType(type)) { + throw new Error( + `TargetCommand.watchTargets invoked with an unknown type: "${type}"` + ); + } + } + + // Notify about already existing target of these types + const targetFronts = [...this._targets].filter(targetFront => + types.includes(targetFront.targetType) + ); + this._pendingWatchTargetInitialization.set( + onAvailable, + new Set(targetFronts) + ); + const promises = targetFronts.map(async targetFront => { + // Attach the targets that aren't attached yet (e.g. the initial top-level target), + // and wait for the other ones to be fully attached. + try { + await targetFront.attachAndInitThread(this); + } catch (e) { + console.error("Error when attaching target:", e); + return; + } + + // It can happen that onAvailable was already called with this targetFront at + // this time (via _onTargetAvailable). If that's the case, we don't want to call + // onAvailable a second time. + if ( + this._pendingWatchTargetInitialization && + this._pendingWatchTargetInitialization.has(onAvailable) && + !this._pendingWatchTargetInitialization + .get(onAvailable) + .has(targetFront) + ) { + return; + } + + try { + // Ensure waiting for eventual async create listeners + // which may setup things regarding the existing targets + // and listen callsite may care about the full initialization + await onAvailable({ + targetFront, + isTargetSwitching: false, + }); + } catch (e) { + // Prevent throwing when onAvailable handler throws on one target + // so that it can try to register the other targets + console.error( + "Exception when calling onAvailable handler", + e.message, + e + ); + } + }); + + for (const type of types) { + this._createListeners.on(type, onAvailable); + if (onDestroyed) { + this._destroyListeners.on(type, onDestroyed); + } + if (onSelected) { + this._selectListeners.on(type, onSelected); + } + } + + await Promise.all(promises); + this._pendingWatchTargetInitialization.delete(onAvailable); + } + + /** + * Stop listening for the creation and/or destruction of a given type of target fronts. + * See `watchTargets()` for documentation of the arguments. + */ + unwatchTargets(options = {}) { + const availableOptions = [ + "types", + "onAvailable", + "onDestroyed", + "onSelected", + ]; + const unsupportedKeys = Object.keys(options).filter( + key => !availableOptions.includes(key) + ); + if (unsupportedKeys.length) { + throw new Error( + `TargetCommand.unwatchTargets does not expect the following options: ${unsupportedKeys.join( + ", " + )}` + ); + } + + const { types, onAvailable, onDestroyed, onSelected } = options; + if (typeof onAvailable != "function") { + throw new Error( + "TargetCommand.unwatchTargets expects a function for the onAvailable option" + ); + } + + for (const type of types) { + if (!this._isValidTargetType(type)) { + throw new Error( + `TargetCommand.unwatchTargets invoked with an unknown type: "${type}"` + ); + } + + this._createListeners.off(type, onAvailable); + if (onDestroyed) { + this._destroyListeners.off(type, onDestroyed); + } + if (onSelected) { + this._selectListeners.off(type, onSelected); + } + } + this._pendingWatchTargetInitialization.delete(onAvailable); + } + + /** + * Retrieve all the current target fronts of a given type. + * + * @param {Array<String>} types + * The types of target to retrieve. Array of TargetCommand.TYPES + * @return {Array<TargetFront>} Array of target fronts matching any of the + * provided types. + */ + getAllTargets(types) { + if (!types?.length) { + throw new Error("getAllTargets expects a non-empty array of types"); + } + + const targets = [...this._targets].filter(target => + types.some(type => this._matchTargetType(type, target)) + ); + + return targets; + } + + /** + * Retrieve all the target fronts in the selected target tree (including the selected + * target itself). + * + * @param {Array<String>} types + * The types of target to retrieve. Array of TargetCommand.TYPES + * @return {Promise<Array<TargetFront>>} Promise that resolves to an array of target fronts. + */ + async getAllTargetsInSelectedTargetTree(types) { + const allTargets = this.getAllTargets(types); + if (this.isTopLevelTargetSelected()) { + return allTargets; + } + + const targets = [this.selectedTargetFront]; + for (const target of allTargets) { + const isInSelectedTree = await target.isTargetAnAncestor( + this.selectedTargetFront + ); + + if (isInSelectedTree) { + targets.push(target); + } + } + return targets; + } + + /** + * For all the target fronts of given types, retrieve all the target-scoped fronts of the given types. + * + * @param {Array<String>} targetTypes + * The types of target to iterate over. Constant of TargetCommand.TYPES. + * @param {String} frontType + * The type of target-scoped front to retrieve. It can be "inspector", "console", "thread",... + * @param {Object} options + * @param {Boolean} options.onlyInSelectedTargetTree + * Set to true to only get the fronts for targets who are in the "targets tree" + * of the selected target. + */ + async getAllFronts( + targetTypes, + frontType, + { onlyInSelectedTargetTree = false } = {} + ) { + if (!Array.isArray(targetTypes) || !targetTypes?.length) { + throw new Error("getAllFronts expects a non-empty array of target types"); + } + const promises = []; + const targets = !onlyInSelectedTargetTree + ? this.getAllTargets(targetTypes) + : await this.getAllTargetsInSelectedTargetTree(targetTypes); + for (const target of targets) { + // For still-attaching worker targets, the thread or console front may not yet be available, + // whereas TargetMixin.getFront will throw if the actorID isn't available in targetForm. + // Also ignore destroyed targets. For some reason the previous methods fetching targets + // can sometime return destroyed targets. + if ( + (frontType == "thread" && !target.targetForm.threadActor) || + (frontType == "console" && !target.targetForm.consoleActor) || + target.isDestroyed() + ) { + continue; + } + + promises.push(target.getFront(frontType)); + } + return Promise.all(promises); + } + + /** + * This function is triggered by an event sent by the TabDescriptor when + * the tab navigates to a distinct process. + * + * @param TargetFront targetFront + * The WindowGlobalTargetFront instance that navigated to another process + */ + async onLocalTabRemotenessChange(targetFront) { + if (this.isServerTargetSwitchingEnabled()) { + // For server-side target switching, everything will be handled by the + // _onTargetAvailable callback. + return; + } + + // TabDescriptor may emit the event with a null targetFront, interpret that as if the previous target + // has already been destroyed + if (targetFront) { + // Wait for the target to be destroyed so that LocalTabCommandsFactory clears its memoized target for this tab + await targetFront.once("target-destroyed"); + } + + // Fetch the new target from the descriptor. + const newTarget = await this.descriptorFront.getTarget(); + + // If a navigation happens while we try to get the target for the page that triggered + // the remoteness change, `getTarget` will return null. In such case, we'll get the + // "next" target through onTargetAvailable so it's safe to bail here. + if (!newTarget) { + console.warn( + `Couldn't get the target for descriptor ${this.descriptorFront.actorID}` + ); + return; + } + + this.switchToTarget(newTarget); + } + + /** + * Reload the current top level target. + * This only works for targets inheriting from WindowGlobalTarget. + * + * @param {Boolean} bypassCache + * If true, the reload will be forced to bypass any cache. + */ + async reloadTopLevelTarget(bypassCache = false) { + if (!this.descriptorFront.traits.supportsReloadDescriptor) { + throw new Error("The top level target doesn't support being reloaded"); + } + + // Wait for the next DOCUMENT_EVENT's dom-complete event + // Wait for waitForNextResource completion before reloading, otherwise we might miss the dom-complete event. + // This can happen if `ResourceCommand.watchResources` made by `waitForNextResource` is still pending + // while the reload already started and finished loading the document early. + const { onResource: onReloaded } = + await this.commands.resourceCommand.waitForNextResource( + this.commands.resourceCommand.TYPES.DOCUMENT_EVENT, + { + ignoreExistingResources: true, + predicate(resource) { + return resource.name == "dom-complete"; + }, + } + ); + + await this.descriptorFront.reloadDescriptor({ bypassCache }); + + await onReloaded; + } + + /** + * Called when the top level target is replaced by a new one. + * Typically when we navigate to another domain which requires to be loaded in a distinct process. + * + * @param {TargetFront} newTarget + * The new top level target to debug. + */ + async switchToTarget(newTarget) { + // Notify about this new target to creation listeners + // _onTargetAvailable will also destroy all previous target before notifying about this new one. + await this._onTargetAvailable(newTarget); + } + + /** + * Called when the user selects a frame in the iframe picker. + * + * @param {WindowGlobalTargetFront} targetFront + * The target front we want the toolbox to focus on. + */ + selectTarget(targetFront) { + return this._onTargetSelected(targetFront); + } + + /** + * Returns true if the top-level frame is the selected one + * + * @returns {Boolean} + */ + isTopLevelTargetSelected() { + return this.selectedTargetFront === this.targetFront; + } + + /** + * Returns true if a non top-level frame is the selected one in the iframe picker. + * + * @returns {Boolean} + */ + isNonTopLevelTargetSelected() { + return this.selectedTargetFront !== this.targetFront; + } + + isTargetRegistered(targetFront) { + return this._targets.has(targetFront); + } + + getParentTarget(targetFront) { + // Note that there are edgecases: + // * Until bug 1741927 is fixed and we remove non-EFT codepath entirely, + // we may receive a `parentInnerWindowId` that doesn't relate to any target. + // This happens when the parent document of the targetFront is a document loaded in the + // same process as its parent document. In such scenario, and only when EFT is disabled, + // we won't instantiate a target for the parent document of the targetFront. + // * `parentInnerWindowId` could be null in some case like for tabs in the MBT + // we should report the top level target as parent. That's what `getParentWindowGlobalTarget` does. + // Once we can stop using getParentWindowGlobalTarget for the other edgecase we will be able to + // replace it with such fallback: `return this.targetFront;`. + // browser_target_command_frames.js will help you get things right. + const { parentInnerWindowId } = targetFront.targetForm; + if (parentInnerWindowId) { + const targets = this.getAllTargets([TargetCommand.TYPES.FRAME]); + const parent = targets.find( + target => target.innerWindowId == parentInnerWindowId + ); + // Until EFT is the only codepath supported (bug 1741927), we will fallback to `getParentWindowGlobalTarget` + // as we may not have a target if the parent is an iframe running in the same process as its parent. + if (parent) { + return parent; + } + } + + // Note that all callsites which care about FRAME additional target + // should all have a toolbox using the watcher actor. + // It should be: MBT, regular tab toolbox and web extension. + // The others which still don't support watcher don't spawn FRAME targets: + // browser content toolbox and service workers. + + return this.watcherFront.getParentWindowGlobalTarget( + targetFront.browsingContextID + ); + } + + isDestroyed() { + return this._isDestroyed; + } + + isServerTargetSwitchingEnabled() { + if (this.descriptorFront.isServerTargetSwitchingEnabled) { + return this.descriptorFront.isServerTargetSwitchingEnabled(); + } + return false; + } + + _isValidTargetType(type) { + return this.ALL_TYPES.includes(type); + } + + destroy() { + this.stopListening(); + this._createListeners.off(); + this._destroyListeners.off(); + this._selectListeners.off(); + + this.#selectedTargetFront = null; + this._isDestroyed = true; + + Services.prefs.removeObserver( + BROWSERTOOLBOX_SCOPE_PREF, + this._updateBrowserToolboxScope + ); + } +} + +/** + * All types of target: + */ +TargetCommand.TYPES = TargetCommand.prototype.TYPES = { + PROCESS: "process", + FRAME: "frame", + WORKER: "worker", + SHARED_WORKER: "shared_worker", + SERVICE_WORKER: "service_worker", +}; +TargetCommand.ALL_TYPES = TargetCommand.prototype.ALL_TYPES = Object.values( + TargetCommand.TYPES +); + +const LegacyTargetWatchers = {}; +loader.lazyRequireGetter( + LegacyTargetWatchers, + TargetCommand.TYPES.PROCESS, + "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js" +); +loader.lazyRequireGetter( + LegacyTargetWatchers, + TargetCommand.TYPES.WORKER, + "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js" +); +loader.lazyRequireGetter( + LegacyTargetWatchers, + TargetCommand.TYPES.SHARED_WORKER, + "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js" +); +loader.lazyRequireGetter( + LegacyTargetWatchers, + TargetCommand.TYPES.SERVICE_WORKER, + "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js" +); + +module.exports = TargetCommand; diff --git a/devtools/shared/commands/target/tests/browser.toml b/devtools/shared/commands/target/tests/browser.toml new file mode 100644 index 0000000000..3a0c59de6f --- /dev/null +++ b/devtools/shared/commands/target/tests/browser.toml @@ -0,0 +1,67 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", + "!/devtools/client/shared/test/highlighter-test-actor.js", + "head.js", + "simple_document.html", + "incremental-js-value-script.sjs", + "fission_document.html", + "fission_iframe.html", + "test_service_worker.js", + "test_sw_page.html", + "test_sw_page_worker.js", + "test_worker.js", +] + +["browser_target_command_bfcache.js"] + +["browser_target_command_browser_workers.js"] + +["browser_target_command_detach.js"] + +["browser_target_command_frames.js"] + +["browser_target_command_frames_popups.js"] + +["browser_target_command_frames_reload_server_side_targets.js"] +skip-if = [ + "os == 'linux' && !fission" # Bug 1855067 +] +["browser_target_command_getAllTargets.js"] + +["browser_target_command_invalid_api_usage.js"] + +["browser_target_command_processes.js"] + +["browser_target_command_reload.js"] + +["browser_target_command_scope_flag.js"] + +["browser_target_command_service_workers.js"] +skip-if = [ + "http3", # Bug 1781324 + "http2", +] + +["browser_target_command_service_workers_navigation.js"] +skip-if = [ + "os == 'linux'", # Bug 1767781 + "win11_2009", # Bug 1767781 +] + +["browser_target_command_switchToTarget.js"] + +["browser_target_command_tab_workers.js"] + +["browser_target_command_tab_workers_bfcache_navigation.js"] +skip-if = ["debug"] # Bug 1721859 + +["browser_target_command_various_descriptors.js"] +skip-if = ["os == 'linux' && bits == 64 && !debug"] #Bug 1701056 + +["browser_target_command_watchTargets.js"] + +["browser_watcher_actor_getter_caching.js"] diff --git a/devtools/shared/commands/target/tests/browser_target_command_bfcache.js b/devtools/shared/commands/target/tests/browser_target_command_bfcache.js new file mode 100644 index 0000000000..598e9c550b --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_bfcache.js @@ -0,0 +1,505 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API when bfcache navigations happen + +const TEST_COM_URL = URL_ROOT_SSL + "simple_document.html"; + +add_task(async function () { + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + info("## Test with bfcache in parent DISABLED"); + await pushPref("fission.bfcacheInParent", false); + await testTopLevelNavigations(false); + await testIframeNavigations(false); + await testTopLevelNavigationsOnDocumentWithIframe(false); + + // bfcacheInParent only works if sessionHistoryInParent is enable + // so only test it if both settings are enabled. + // (it looks like sessionHistoryInParent is enabled by default when fission is enabled) + if (Services.appinfo.sessionHistoryInParent) { + info("## Test with bfcache in parent ENABLED"); + await pushPref("fission.bfcacheInParent", true); + await testTopLevelNavigations(true); + await testIframeNavigations(true); + await testTopLevelNavigationsOnDocumentWithIframe(true); + } +}); + +async function testTopLevelNavigations(bfcacheInParent) { + info(" # Test TOP LEVEL navigations"); + // Create a TargetCommand for a given test tab + const tab = await addTab(TEST_COM_URL); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const onAvailable = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok(targetFront.isTopLevel, "all targets of this test are top level"); + targets.push(targetFront); + }; + const destroyedTargets = []; + const onDestroyed = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok(targetFront.isTopLevel, "all targets of this test are top level"); + destroyedTargets.push(targetFront); + }; + + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + is(targets.length, 1, "retrieved only the top level target"); + is(targets[0], targetCommand.targetFront, "the target is the top level one"); + is( + destroyedTargets.length, + 0, + "We get no destruction when calling watchTargets" + ); + ok( + targets[0].targetForm.followWindowGlobalLifeCycle, + "the first server side target follows the WindowGlobal lifecycle, when server target switching is enabled" + ); + + // Navigate to the same page with query params + info("Load the second page"); + const secondPageUrl = TEST_COM_URL + "?second-load"; + const previousBrowsingContextID = gBrowser.selectedBrowser.browsingContext.id; + ok( + previousBrowsingContextID, + "Fetch the tab's browsing context id before navigation" + ); + const onLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + secondPageUrl + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + secondPageUrl + ); + await onLoaded; + + // Assert BrowsingContext changes as it impact the behavior of targets + if (bfcacheInParent) { + isnot( + previousBrowsingContextID, + gBrowser.selectedBrowser.browsingContext.id, + "When bfcacheInParent is enabled, same-origin navigations spawn new BrowsingContext" + ); + } else { + is( + previousBrowsingContextID, + gBrowser.selectedBrowser.browsingContext.id, + "When bfcacheInParent is disabled, same-origin navigations re-use the same BrowsingContext" + ); + } + + // Same-origin navigations also spawn a new top level target + await waitFor( + () => targets.length == 2, + "wait for the next top level target" + ); + is( + targets[1], + targetCommand.targetFront, + "the second target is the top level one" + ); + // As targetFront.url isn't reliable and might be about:blank, + // try to assert that we got the right target via other means. + // outerWindowID should change when navigating to another process, + // while it would stay equal for in-process navigations. + is( + targets[1].outerWindowID, + gBrowser.selectedBrowser.outerWindowID, + "the second target is for the second page" + ); + ok( + targets[1].targetForm.followWindowGlobalLifeCycle, + "the new server side target follows the WindowGlobal lifecycle" + ); + ok(targets[0].isDestroyed(), "the first target is destroyed"); + is(destroyedTargets.length, 1, "We get one target being destroyed..."); + is(destroyedTargets[0], targets[0], "...and that's the first one"); + + // Go back to the first page, this should be a bfcache navigation, and, + // we should get a new target + info("Go back to the first page"); + gBrowser.selectedBrowser.goBack(); + + await waitFor( + () => targets.length == 3, + "wait for the next top level target" + ); + is( + targets[2], + targetCommand.targetFront, + "the third target is the top level one" + ); + // Here as this is revived from cache, the url should always be correct + is(targets[2].url, TEST_COM_URL, "the third target is for the first url"); + ok( + targets[2].targetForm.followWindowGlobalLifeCycle, + "the third target for bfcache navigations is following the WindowGlobal lifecycle" + ); + ok(targets[1].isDestroyed(), "the second target is destroyed"); + is( + destroyedTargets.length, + 2, + "We get one additional target being destroyed..." + ); + is(destroyedTargets[1], targets[1], "...and that's the second one"); + + // Wait for full attach in order to having breaking any pending requests + // when navigating to another page and switching to new process and target. + await waitForAllTargetsToBeAttached(targetCommand); + + // Go forward and resurect the second page, this should also be a bfcache navigation, and, + // get a new target. + info("Go forward to the second page"); + + // When a new target will be created, we need to wait until it's fully processed + // to avoid pending promises. + const onNewTargetProcessed = bfcacheInParent + ? new Promise(resolve => { + targetCommand.on( + "processed-available-target", + function onProcessedAvailableTarget(targetFront) { + if (targetFront === targets[3]) { + resolve(); + targetCommand.off( + "processed-available-target", + onProcessedAvailableTarget + ); + } + } + ); + }) + : null; + + gBrowser.selectedBrowser.goForward(); + + await waitFor( + () => targets.length == 4, + "wait for the next top level target" + ); + is( + targets[3], + targetCommand.targetFront, + "the 4th target is the top level one" + ); + // Same here, as the document is revived from the cache, the url should always be correct + is(targets[3].url, secondPageUrl, "the 4th target is for the second url"); + ok( + targets[3].targetForm.followWindowGlobalLifeCycle, + "the 4th target for bfcache navigations is following the WindowGlobal lifecycle" + ); + ok(targets[2].isDestroyed(), "the third target is destroyed"); + is( + destroyedTargets.length, + 3, + "We get one additional target being destroyed..." + ); + is(destroyedTargets[2], targets[2], "...and that's the third one"); + + // Wait for full attach in order to having breaking any pending requests + // when navigating to another page and switching to new process and target. + await waitForAllTargetsToBeAttached(targetCommand); + await onNewTargetProcessed; + + await waitForAllTargetsToBeAttached(targetCommand); + + targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable }); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} + +async function testTopLevelNavigationsOnDocumentWithIframe(bfcacheInParent) { + info(" # Test TOP LEVEL navigations on document with iframe"); + // Create a TargetCommand for a given test tab + const tab = + await addTab(`https://example.com/document-builder.sjs?id=top&html= + <h1>Top level</h1> + <iframe src="${encodeURIComponent( + "https://example.com/document-builder.sjs?id=iframe&html=<h2>In iframe</h2>" + )}"> + </iframe>`); + const getLocationIdParam = url => + new URLSearchParams(new URL(url).search).get("id"); + + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const onAvailable = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + targets.push(targetFront); + }; + const destroyedTargets = []; + const onDestroyed = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + destroyedTargets.push(targetFront); + }; + + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + if (isEveryFrameTargetEnabled()) { + is( + targets.length, + 2, + "retrieved targets for top level and iframe documents" + ); + is( + targets[0], + targetCommand.targetFront, + "the target is the top level one" + ); + is( + getLocationIdParam(targets[1].url), + "iframe", + "the second target is the iframe one" + ); + } else { + is(targets.length, 1, "retrieved only the top level target"); + is( + targets[0], + targetCommand.targetFront, + "the target is the top level one" + ); + } + + is( + destroyedTargets.length, + 0, + "We get no destruction when calling watchTargets" + ); + + info("Navigate to a new page"); + let targetCountBeforeNavigation = targets.length; + const secondPageUrl = `https://example.com/document-builder.sjs?html=second`; + const onLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + secondPageUrl + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + secondPageUrl + ); + await onLoaded; + + // Same-origin navigations also spawn a new top level target + await waitFor( + () => targets.length == targetCountBeforeNavigation + 1, + "wait for the next top level target" + ); + is( + targets.at(-1), + targetCommand.targetFront, + "the new target is the top level one" + ); + + ok(targets[0].isDestroyed(), "the first target is destroyed"); + if (isEveryFrameTargetEnabled()) { + ok(targets[1].isDestroyed(), "the second target is destroyed"); + is(destroyedTargets.length, 2, "The two targets were destroyed"); + } else { + is(destroyedTargets.length, 1, "Only one target was destroyed"); + } + + // Go back to the first page, this should be a bfcache navigation, and, + // we should get a new target (or 2 if EFT is enabled) + targetCountBeforeNavigation = targets.length; + info("Go back to the first page"); + gBrowser.selectedBrowser.goBack(); + + await waitFor( + () => + targets.length === + targetCountBeforeNavigation + (isEveryFrameTargetEnabled() ? 2 : 1), + "wait for the next top level target" + ); + + if (isEveryFrameTargetEnabled()) { + await waitFor(() => targets.at(-2).url && targets.at(-1).url); + is( + getLocationIdParam(targets.at(-2).url), + "top", + "the first new target is for the top document…" + ); + is( + getLocationIdParam(targets.at(-1).url), + "iframe", + "…and the second one is for the iframe" + ); + } else { + is( + getLocationIdParam(targets.at(-1).url), + "top", + "the new target is for the first url" + ); + } + + ok( + targets[targetCountBeforeNavigation - 1].isDestroyed(), + "the target for the second page is destroyed" + ); + is( + destroyedTargets.length, + targetCountBeforeNavigation, + "We get one additional target being destroyed…" + ); + is( + destroyedTargets.at(-1), + targets[targetCountBeforeNavigation - 1], + "…and that's the second page one" + ); + + await waitForAllTargetsToBeAttached(targetCommand); + + targetCommand.unwatchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} + +async function testIframeNavigations() { + info(" # Test IFRAME navigations"); + // Create a TargetCommand for a given test tab + const tab = await addTab( + `http://example.org/document-builder.sjs?html=<iframe src="${TEST_COM_URL}"></iframe>` + ); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const onAvailable = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + targets.push(targetFront); + }; + await targetCommand.watchTargets({ types: [TYPES.FRAME], onAvailable }); + + // When fission/EFT is off, there isn't much to test for iframes as they are debugged + // when the unique top level target + if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) { + is( + targets.length, + 1, + "Without fission/EFT, there is only the top level target" + ); + await commands.destroy(); + return; + } + is(targets.length, 2, "retrieved the top level and the iframe targets"); + is( + targets[0], + targetCommand.targetFront, + "the first target is the top level one" + ); + is(targets[1].url, TEST_COM_URL, "the second target is the iframe one"); + + // Navigate to the same page with query params + info("Load the second page"); + const secondPageUrl = TEST_COM_URL + "?second-load"; + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [secondPageUrl], + function (url) { + const iframe = content.document.querySelector("iframe"); + iframe.src = url; + } + ); + + await waitFor(() => targets.length == 3, "wait for the next target"); + is(targets[2].url, secondPageUrl, "the second target is for the second url"); + ok(targets[1].isDestroyed(), "the first target is destroyed"); + + // Go back to the first page, this should be a bfcache navigation, and, + // we should get a new target + info("Go back to the first page"); + const iframeBrowsingContext = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function () { + const iframe = content.document.querySelector("iframe"); + return iframe.browsingContext; + } + ); + await SpecialPowers.spawn(iframeBrowsingContext, [], function () { + content.history.back(); + }); + + await waitFor(() => targets.length == 4, "wait for the next target"); + is(targets[3].url, TEST_COM_URL, "the third target is for the first url"); + ok(targets[2].isDestroyed(), "the second target is destroyed"); + + // Go forward and resurect the second page, this should also be a bfcache navigation, and, + // get a new target. + info("Go forward to the second page"); + await SpecialPowers.spawn(iframeBrowsingContext, [], function () { + content.history.forward(); + }); + + await waitFor(() => targets.length == 5, "wait for the next target"); + is(targets[4].url, secondPageUrl, "the 4th target is for the second url"); + ok(targets[3].isDestroyed(), "the third target is destroyed"); + + targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable }); + + await waitForAllTargetsToBeAttached(targetCommand); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_browser_workers.js b/devtools/shared/commands/target/tests/browser_target_command_browser_workers.js new file mode 100644 index 0000000000..181cfa2614 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_browser_workers.js @@ -0,0 +1,246 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API around workers + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const WORKER_FILE = "test_worker.js"; +const CHROME_WORKER_URL = CHROME_URL_ROOT + WORKER_FILE; +const SERVICE_WORKER_URL = URL_ROOT_SSL + "test_service_worker.js"; + +add_task(async function () { + // Enabled fission's pref as the TargetCommand is almost disabled without it + await pushPref("devtools.browsertoolbox.scope", "everything"); + + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + const tab = await addTab(FISSION_TEST_URL); + + info("Test TargetCommand against workers via the parent process target"); + + // Instantiate a worker in the parent process + // eslint-disable-next-line no-unused-vars + const worker = new Worker(CHROME_WORKER_URL + "#simple-worker"); + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker(CHROME_WORKER_URL + "#shared-worker"); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + await targetCommand.startListening(); + + // Very naive sanity check against getAllTargets([workerType]) + info("Check that getAllTargets returned the expected targets"); + const workers = await targetCommand.getAllTargets([TYPES.WORKER]); + const hasWorker = workers.find(workerTarget => { + return workerTarget.url == CHROME_WORKER_URL + "#simple-worker"; + }); + ok(hasWorker, "retrieve the target for the worker"); + + const sharedWorkers = await targetCommand.getAllTargets([ + TYPES.SHARED_WORKER, + ]); + const hasSharedWorker = sharedWorkers.find(workerTarget => { + return workerTarget.url == CHROME_WORKER_URL + "#shared-worker"; + }); + ok(hasSharedWorker, "retrieve the target for the shared worker"); + + const serviceWorkers = await targetCommand.getAllTargets([ + TYPES.SERVICE_WORKER, + ]); + const hasServiceWorker = serviceWorkers.find(workerTarget => { + return workerTarget.url == SERVICE_WORKER_URL; + }); + ok(hasServiceWorker, "retrieve the target for the service worker"); + + info( + "Check that calling getAllTargets again return the same target instances" + ); + const workers2 = await targetCommand.getAllTargets([TYPES.WORKER]); + const sharedWorkers2 = await targetCommand.getAllTargets([ + TYPES.SHARED_WORKER, + ]); + const serviceWorkers2 = await targetCommand.getAllTargets([ + TYPES.SERVICE_WORKER, + ]); + is(workers2.length, workers.length, "retrieved the same number of workers"); + is( + sharedWorkers2.length, + sharedWorkers.length, + "retrieved the same number of shared workers" + ); + is( + serviceWorkers2.length, + serviceWorkers.length, + "retrieved the same number of service workers" + ); + + workers.sort(sortFronts); + workers2.sort(sortFronts); + sharedWorkers.sort(sortFronts); + sharedWorkers2.sort(sortFronts); + serviceWorkers.sort(sortFronts); + serviceWorkers2.sort(sortFronts); + + for (let i = 0; i < workers.length; i++) { + is(workers[i], workers2[i], `worker ${i} targets are the same`); + } + for (let i = 0; i < sharedWorkers2.length; i++) { + is( + sharedWorkers[i], + sharedWorkers2[i], + `shared worker ${i} targets are the same` + ); + } + for (let i = 0; i < serviceWorkers2.length; i++) { + is( + serviceWorkers[i], + serviceWorkers2[i], + `service worker ${i} targets are the same` + ); + } + + info( + "Check that watchTargets will call the create callback for all existing workers" + ); + const targets = []; + const topLevelTarget = await commands.targetCommand.targetFront; + const onAvailable = async ({ targetFront }) => { + ok( + targetFront.targetType === TYPES.WORKER || + targetFront.targetType === TYPES.SHARED_WORKER || + targetFront.targetType === TYPES.SERVICE_WORKER, + "We are only notified about worker targets" + ); + ok( + targetFront == topLevelTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.push(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.WORKER, TYPES.SHARED_WORKER, TYPES.SERVICE_WORKER], + onAvailable, + }); + is( + targets.length, + workers.length + sharedWorkers.length + serviceWorkers.length, + "retrieved the same number of workers via watchTargets" + ); + + targets.sort(sortFronts); + const allWorkers = workers + .concat(sharedWorkers, serviceWorkers) + .sort(sortFronts); + + for (let i = 0; i < allWorkers.length; i++) { + is( + allWorkers[i], + targets[i], + `worker ${i} targets are the same via watchTargets` + ); + } + + targetCommand.unwatchTargets({ + types: [TYPES.WORKER, TYPES.SHARED_WORKER, TYPES.SERVICE_WORKER], + onAvailable, + }); + + // Create a new worker and see if the worker target is reported + const onWorkerCreated = new Promise(resolve => { + const onAvailable2 = async ({ targetFront }) => { + if (targets.includes(targetFront)) { + return; + } + targetCommand.unwatchTargets({ + types: [TYPES.WORKER], + onAvailable: onAvailable2, + }); + resolve(targetFront); + }; + targetCommand.watchTargets({ + types: [TYPES.WORKER], + onAvailable: onAvailable2, + }); + }); + // eslint-disable-next-line no-unused-vars + const worker2 = new Worker(CHROME_WORKER_URL + "#second"); + info("Wait for the second worker to be created"); + const workerTarget = await onWorkerCreated; + + is( + workerTarget.url, + CHROME_WORKER_URL + "#second", + "This worker target is about the new worker" + ); + is( + workerTarget.name, + "test_worker.js#second", + "The worker target has the expected name" + ); + + const workers3 = await targetCommand.getAllTargets([TYPES.WORKER]); + const hasWorker2 = workers3.find( + ({ url }) => url == `${CHROME_WORKER_URL}#second` + ); + ok(hasWorker2, "retrieve the target for tab via getAllTargets"); + + info( + "Check that terminating the worker does trigger the onDestroyed callback" + ); + const onWorkerDestroyed = new Promise(resolve => { + const emptyFn = () => {}; + const onDestroyed = ({ targetFront }) => { + targetCommand.unwatchTargets({ + types: [TYPES.WORKER], + onAvailable: emptyFn, + onDestroyed, + }); + resolve(targetFront); + }; + + targetCommand.watchTargets({ + types: [TYPES.WORKER], + onAvailable: emptyFn, + onDestroyed, + }); + }); + worker2.terminate(); + const workerTargetFront = await onWorkerDestroyed; + ok(true, "onDestroyed was called when the worker was terminated"); + + workerTargetFront.isTopLevel; + ok( + true, + "isTopLevel can be called on the target front after onDestroyed was called" + ); + + workerTargetFront.name; + ok( + true, + "name can be accessed on the target front after onDestroyed was called" + ); + + targetCommand.destroy(); + + info("Unregister service workers so they don't appear in other tests."); + await unregisterAllServiceWorkers(commands.client); + + await commands.destroy(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +function sortFronts(f1, f2) { + return f1.actorID < f2.actorID; +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_detach.js b/devtools/shared/commands/target/tests/browser_target_command_detach.js new file mode 100644 index 0000000000..a0056cd7a5 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_detach.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand's when detaching the top target +// +// Do this with the "remote tab" codepath, which will avoid +// destroying the DevToolsClient when the target is destroyed. +// Otherwise, with "local tab", the client is closed and everything is destroy +// on both client and server side. + +const TEST_URL = "data:text/html,test-page"; + +add_task(async function () { + info(" ### Test detaching the top target"); + + // Create a TargetCommand for a given test tab + const tab = await addTab(TEST_URL); + + info("Create a first commands, which will destroy its top target"); + const commands = await CommandsFactory.forRemoteTab( + tab.linkedBrowser.browserId + ); + const targetCommand = commands.targetCommand; + + // We have to start listening in order to ensure having a targetFront available + await targetCommand.startListening(); + + info("Call any target front method, to ensure it works fine"); + await targetCommand.targetFront.focus(); + + // Destroying the target front should end up calling "WindowGlobalTargetActor.detach" + // which should destroy the target on the server side + await targetCommand.targetFront.destroy(); + + info( + "Now create a second commands after destroy, to see if we can spawn a new, functional target" + ); + const secondCommands = await CommandsFactory.forRemoteTab( + tab.linkedBrowser.browserId, + { + client: commands.client, + } + ); + const secondTargetCommand = secondCommands.targetCommand; + + // We have to start listening in order to ensure having a targetFront available + await secondTargetCommand.startListening(); + + info("Call any target front method, to ensure it works fine"); + await secondTargetCommand.targetFront.focus(); + + BrowserTestUtils.removeTab(tab); + + info("Close the two commands"); + await commands.destroy(); + await secondCommands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_frames.js b/devtools/shared/commands/target/tests/browser_target_command_frames.js new file mode 100644 index 0000000000..6aa0655b64 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_frames.js @@ -0,0 +1,649 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API around frames + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const IFRAME_URL = URL_ROOT_ORG_SSL + "fission_iframe.html"; +const SECOND_PAGE_URL = "https://example.org/document-builder.sjs?html=org"; + +const PID_REGEXP = /^\d+$/; + +add_task(async function () { + // Disable bfcache for Fission for now. + // If Fission is disabled, the pref is no-op. + await SpecialPowers.pushPrefEnv({ + set: [["fission.bfcacheInParent", false]], + }); + + // Enabled fission prefs + await pushPref("devtools.browsertoolbox.scope", "everything"); + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + // Test fetching the frames from the main process descriptor + await testBrowserFrames(); + + // Test fetching the frames from a tab descriptor + await testTabFrames(); + + // Test what happens with documents running in the parent process + await testOpeningOnParentProcessDocument(); + await testNavigationToParentProcessDocument(); + + // Test what happens with about:blank documents + await testOpeningOnAboutBlankDocument(); + await testNavigationToAboutBlankDocument(); + + await testNestedIframes(); +}); + +async function testOpeningOnParentProcessDocument() { + info("Test opening against a parent process document"); + const tab = await addTab("about:robots"); + is( + tab.linkedBrowser.browsingContext.currentWindowGlobal.osPid, + -1, + "The tab is loaded in the parent process" + ); + + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + is(frames.length, 1); + is(frames[0].url, "about:robots", "target url is correct"); + is( + frames[0], + targetCommand.targetFront, + "the target is the current top level one" + ); + + await commands.destroy(); +} + +async function testNavigationToParentProcessDocument() { + info("Test navigating to parent process document"); + const firstLocation = "data:text/html,foo"; + const secondLocation = "about:robots"; + + const tab = await addTab(firstLocation); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + // When the first top level target is created from the server, + // `startListening` emits a spurious switched-target event + // which isn't necessarily emited before it resolves. + // So ensure waiting for it, otherwise we may resolve too eagerly + // in our expected listener. + const onSwitchedTarget1 = targetCommand.once("switched-target"); + await targetCommand.startListening(); + info("wait for first top level target"); + await onSwitchedTarget1; + + const firstTarget = targetCommand.targetFront; + is(firstTarget.url, firstLocation, "first target url is correct"); + + info("Navigate to a parent process page"); + const onSwitchedTarget = targetCommand.once("switched-target"); + const browser = tab.linkedBrowser; + const onLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, secondLocation); + await onLoaded; + is( + browser.browsingContext.currentWindowGlobal.osPid, + -1, + "The tab is loaded in the parent process" + ); + + await onSwitchedTarget; + isnot(targetCommand.targetFront, firstTarget, "got a new target"); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + is(frames.length, 1); + is(frames[0].url, secondLocation, "second target url is correct"); + is( + frames[0], + targetCommand.targetFront, + "second target is the current top level one" + ); + + await commands.destroy(); +} + +async function testOpeningOnAboutBlankDocument() { + info("Test opening against about:blank document"); + const tab = await addTab("about:blank"); + + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + is(frames.length, 1); + is(frames[0].url, "about:blank", "target url is correct"); + is( + frames[0], + targetCommand.targetFront, + "the target is the current top level one" + ); + + await commands.destroy(); +} + +async function testNavigationToAboutBlankDocument() { + info("Test navigating to about:blank"); + const firstLocation = "data:text/html,foo"; + const secondLocation = "about:blank"; + + const tab = await addTab(firstLocation); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + // When the first top level target is created from the server, + // `startListening` emits a spurious switched-target event + // which isn't necessarily emited before it resolves. + // So ensure waiting for it, otherwise we may resolve too eagerly + // in our expected listener. + const onSwitchedTarget1 = targetCommand.once("switched-target"); + await targetCommand.startListening(); + info("wait for first top level target"); + await onSwitchedTarget1; + + const firstTarget = targetCommand.targetFront; + is(firstTarget.url, firstLocation, "first target url is correct"); + + info("Navigate to about:blank page"); + const onSwitchedTarget = targetCommand.once("switched-target"); + const browser = tab.linkedBrowser; + const onLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, secondLocation); + await onLoaded; + + await onSwitchedTarget; + isnot(targetCommand.targetFront, firstTarget, "got a new target"); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + is(frames.length, 1); + is(frames[0].url, secondLocation, "second target url is correct"); + is( + frames[0], + targetCommand.targetFront, + "second target is the current top level one" + ); + + await commands.destroy(); +} + +async function testBrowserFrames() { + info("Test TargetCommand against frames via the parent process target"); + + const aboutBlankTab = await addTab("about:blank"); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + await targetCommand.startListening(); + + // Very naive sanity check against getAllTargets([frame]) + const frames = await targetCommand.getAllTargets([TYPES.FRAME]); + const hasBrowserDocument = frames.find( + frameTarget => frameTarget.url == window.location.href + ); + ok(hasBrowserDocument, "retrieve the target for the browser document"); + + const hasAboutBlankDocument = frames.find( + frameTarget => + frameTarget.browsingContextID == + aboutBlankTab.linkedBrowser.browsingContext.id + ); + ok(hasAboutBlankDocument, "retrieve the target for the about:blank tab"); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames2 = await targetCommand.getAllTargets([TYPES.FRAME]); + is(frames2.length, frames.length, "retrieved the same number of frames"); + + function sortFronts(f1, f2) { + return f1.actorID < f2.actorID; + } + frames.sort(sortFronts); + frames2.sort(sortFronts); + for (let i = 0; i < frames.length; i++) { + is(frames[i], frames2[i], `frame ${i} targets are the same`); + } + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const topLevelTarget = targetCommand.targetFront; + + const noParentTarget = await topLevelTarget.getParentTarget(); + is(noParentTarget, null, "The top level target has no parent target"); + + const onAvailable = ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront == topLevelTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + ok( + PID_REGEXP.test(targetFront.processID), + `Target has processID of expected shape (${targetFront.processID})` + ); + targets.push(targetFront); + }; + await targetCommand.watchTargets({ types: [TYPES.FRAME], onAvailable }); + is( + targets.length, + frames.length, + "retrieved the same number of frames via watchTargets" + ); + + frames.sort(sortFronts); + targets.sort(sortFronts); + for (let i = 0; i < frames.length; i++) { + is( + frames[i], + targets[i], + `frame ${i} targets are the same via watchTargets` + ); + } + + async function addTabAndAssertNewTarget(url) { + const previousTargetCount = targets.length; + const tab = await addTab(url); + await waitFor( + () => targets.length == previousTargetCount + 1, + "Wait for all expected targets after tab opening" + ); + is( + targets.length, + previousTargetCount + 1, + "Opening a tab reported a new frame" + ); + const newTabTarget = targets.at(-1); + is(newTabTarget.url, url, "This frame target is about the new tab"); + // Internaly, the tab, which uses a <browser type='content'> element is considered detached from their owner document + // and so the target is having a null parentInnerWindowId. But the framework will attach all non-top-level targets + // as children of the top level. + const tabParentTarget = await newTabTarget.getParentTarget(); + is( + tabParentTarget, + targetCommand.targetFront, + "tab's WindowGlobal/BrowsingContext is detached and has no parent, but we report them as children of the top level target" + ); + + const frames3 = await targetCommand.getAllTargets([TYPES.FRAME]); + const hasTabDocument = frames3.find(target => target.url == url); + ok(hasTabDocument, "retrieve the target for tab via getAllTargets"); + + return tab; + } + + info("Open a tab loaded in content process"); + await addTabAndAssertNewTarget("data:text/html,content-process-page"); + + info("Open a tab loaded in the parent process"); + const parentProcessTab = await addTabAndAssertNewTarget("about:robots"); + is( + parentProcessTab.linkedBrowser.browsingContext.currentWindowGlobal.osPid, + -1, + "The tab is loaded in the parent process" + ); + + info("Open a new content window via window.open"); + info("First open a tab on .org domain"); + const tabUrl = "https://example.org/document-builder.sjs?html=org"; + await addTabAndAssertNewTarget(tabUrl); + const previousTargetCount = targets.length; + + info("Then open a popup on .com domain"); + const popupUrl = "https://example.com/document-builder.sjs?html=com"; + const onPopupOpened = BrowserTestUtils.waitForNewTab(gBrowser, popupUrl); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [popupUrl], async url => { + content.window.open(url, "_blank"); + }); + await onPopupOpened; + + await waitFor( + () => targets.length == previousTargetCount + 1, + "Wait for all expected targets after window.open()" + ); + is( + targets.length, + previousTargetCount + 1, + "Opening a new content window reported a new frame" + ); + is( + targets.at(-1).url, + popupUrl, + "This frame target is about the new content window" + ); + + // About:blank are a bit special because we ignore a transcient about:blank + // document when navigating to another process. But we should not ignore + // tabs, loading a real, final about:blank document. + info("Open a tab with about:blank"); + await addTabAndAssertNewTarget("about:blank"); + + // Until we start spawning target for all WindowGlobals, + // including the one running in the same process as their parent, + // we won't create dedicated target for new top level windows. + // Instead, these document will be debugged via the ParentProcessTargetActor. + info("Open a top level chrome window"); + const expectedTargets = targets.length; + const chromeWindow = Services.ww.openWindow( + null, + "about:robots", + "_blank", + "chrome", + null + ); + await wait(250); + is( + targets.length, + expectedTargets, + "New top level window shouldn't spawn new target" + ); + chromeWindow.close(); + + targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable }); + + targetCommand.destroy(); + await waitForAllTargetsToBeAttached(targetCommand); + + await commands.destroy(); +} + +async function testTabFrames(mainRoot) { + info("Test TargetCommand against frames via a tab target"); + + // Create a TargetCommand for a given test tab + const tab = await addTab(FISSION_TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames = await targetCommand.getAllTargets([TYPES.FRAME]); + // When fission is enabled, we also get the remote example.org iframe. + const expectedFramesCount = + isFissionEnabled() || isEveryFrameTargetEnabled() ? 2 : 1; + is( + frames.length, + expectedFramesCount, + "retrieved the expected number of targets" + ); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const destroyedTargets = []; + const topLevelTarget = targetCommand.targetFront; + const onAvailable = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + PID_REGEXP.test(targetFront.processID), + `Target has processID of expected shape (${targetFront.processID})` + ); + targets.push({ targetFront, isTargetSwitching }); + }; + const onDestroyed = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront == topLevelTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + destroyedTargets.push({ targetFront, isTargetSwitching }); + }; + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + is( + targets.length, + frames.length, + "retrieved the same number of frames via watchTargets" + ); + is(destroyedTargets.length, 0, "Should be no destroyed target initialy"); + + for (const frame of frames) { + ok( + targets.find(({ targetFront }) => targetFront === frame), + "frame " + frame.actorID + " target is the same via watchTargets" + ); + } + is( + targets[0].targetFront.url, + FISSION_TEST_URL, + "First target should be the top document one" + ); + is( + targets[0].targetFront.isTopLevel, + true, + "First target is a top level one" + ); + is( + !targets[0].isTargetSwitching, + true, + "First target is not considered as a target switching" + ); + const noParentTarget = await targets[0].targetFront.getParentTarget(); + is(noParentTarget, null, "The top level target has no parent target"); + + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + is( + targets[1].targetFront.url, + IFRAME_URL, + "Second target should be the iframe one" + ); + is( + !targets[1].targetFront.isTopLevel, + true, + "Iframe target isn't top level" + ); + is( + !targets[1].isTargetSwitching, + true, + "Iframe target isn't a target swich" + ); + const parentTarget = await targets[1].targetFront.getParentTarget(); + is( + parentTarget, + targets[0].targetFront, + "The parent target for the iframe is the top level target" + ); + } + + // Before navigating to another process, ensure cleaning up everything from the first page + await waitForAllTargetsToBeAttached(targetCommand); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); + + info("Navigate to another domain and process (if fission is enabled)"); + // When a new target will be created, we need to wait until it's fully processed + // to avoid pending promises. + const onNewTargetProcessed = targetCommand.once("processed-available-target"); + + const browser = tab.linkedBrowser; + const onLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, SECOND_PAGE_URL); + await onLoaded; + + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + const afterNavigationFramesCount = 3; + await waitFor( + () => targets.length == afterNavigationFramesCount, + "Wait for all expected targets after navigation" + ); + is( + targets.length, + afterNavigationFramesCount, + "retrieved all targets after navigation" + ); + // As targetFront.url isn't reliable and might be about:blank, + // try to assert that we got the right target via other means. + // outerWindowID should change when navigating to another process, + // while it would stay equal for in-process navigations. + is( + targets[2].targetFront.outerWindowID, + browser.outerWindowID, + "The new target should be the newly loaded document" + ); + is( + targets[2].isTargetSwitching, + true, + "and should be flagged as a target switching" + ); + + is( + destroyedTargets.length, + 2, + "The two existing targets should be destroyed" + ); + is( + destroyedTargets[0].targetFront, + targets[1].targetFront, + "The first destroyed should be the iframe one" + ); + is( + destroyedTargets[0].isTargetSwitching, + false, + "the target destruction is not flagged as target switching for iframes" + ); + is( + destroyedTargets[1].targetFront, + targets[0].targetFront, + "The second destroyed should be the previous top level one (because it is delayed to be fired *after* will-navigate)" + ); + is( + destroyedTargets[1].isTargetSwitching, + true, + "the target destruction is flagged as target switching" + ); + } else { + await waitFor( + () => targets.length == 2, + "Wait for all expected targets after navigation" + ); + is( + destroyedTargets.length, + 1, + "with JSWindowActor based target, the top level target is destroyed" + ); + is( + targetCommand.targetFront, + targets[1].targetFront, + "we got a new target" + ); + ok( + !targetCommand.targetFront.isDestroyed(), + "that target is not destroyed" + ); + ok( + targets[0].targetFront.isDestroyed(), + "but the previous one is destroyed" + ); + } + + await onNewTargetProcessed; + + targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable }); + + targetCommand.destroy(); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} + +async function testNestedIframes() { + info("Test TargetCommand against nested frames"); + + const nestedIframeUrl = `https://example.com/document-builder.sjs?html=${encodeURIComponent( + "<title>second</title><h3>second level iframe</h3>" + )}&delay=500`; + + const testUrl = `data:text/html;charset=utf-8, + <h1>Top-level</h1> + <iframe id=first-level + src='data:text/html;charset=utf-8,${encodeURIComponent( + `<title>first</title><h2>first level iframe</h2><iframe id=second-level src="${nestedIframeUrl}"></iframe>` + )}' + ></iframe>`; + + // Create a TargetCommand for a given test tab + const tab = await addTab(testUrl); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames = await targetCommand.getAllTargets([TYPES.FRAME]); + + is(frames[0], targetCommand.targetFront, "First target is the top level one"); + const topParent = await frames[0].getParentTarget(); + is(topParent, null, "Top level target has no parent"); + + if (isEveryFrameTargetEnabled()) { + const firstIframeTarget = frames.find(target => target.title == "first"); + ok( + firstIframeTarget, + "With EFT, got the target for the first level iframe" + ); + const firstParent = await firstIframeTarget.getParentTarget(); + is( + firstParent, + targetCommand.targetFront, + "With EFT, first level has top level target as parent" + ); + + const secondIframeTarget = frames.find(target => target.title == "second"); + ok(secondIframeTarget, "Got the target for the second level iframe"); + const secondParent = await secondIframeTarget.getParentTarget(); + is( + secondParent, + firstIframeTarget, + "With EFT, second level has the first level target as parent" + ); + } else if (isFissionEnabled()) { + const secondIframeTarget = frames.find(target => target.title == "second"); + ok(secondIframeTarget, "Got the target for the second level iframe"); + const secondParent = await secondIframeTarget.getParentTarget(); + is( + secondParent, + targetCommand.targetFront, + "With fission, second level has top level target as parent" + ); + } + + await commands.destroy(); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_frames_popups.js b/devtools/shared/commands/target/tests/browser_target_command_frames_popups.js new file mode 100644 index 0000000000..68f7244671 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_frames_popups.js @@ -0,0 +1,168 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we create targets for popups + +const TEST_URL = "https://example.org/document-builder.sjs?html=main page"; +const POPUP_URL = "https://example.com/document-builder.sjs?html=popup"; +const POPUP_SECOND_URL = + "https://example.com/document-builder.sjs?html=popup-navigated"; + +add_task(async function () { + await pushPref("devtools.popups.debug", true); + // We expect to create a target for a same-process iframe + // in the test against window.open to load a document in an iframe. + await pushPref("devtools.every-frame-target.enabled", true); + + // Create a TargetCommand for a given test tab + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const destroyedTargets = []; + const onAvailable = ({ targetFront }) => { + targets.push(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + destroyedTargets.push(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + is(targets.length, 1, "At first, we only get one target"); + is( + targets[0], + targetCommand.targetFront, + "And this target is the top level one" + ); + + info("Open a popup"); + const firstPopupBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [POPUP_URL], + url => { + const win = content.open(url); + return win.browsingContext; + } + ); + + await waitFor(() => targets.length === 2); + ok(true, "We are notified about the first popup's target"); + + is( + targets[1].browsingContextID, + firstPopupBrowsingContext.id, + "the new target is for the popup" + ); + is(targets[1].url, POPUP_URL, "the new target has the right url"); + + info("Navigate the popup to a second location"); + await SpecialPowers.spawn( + firstPopupBrowsingContext, + [POPUP_SECOND_URL], + url => { + content.location.href = url; + } + ); + + await waitFor(() => targets.length === 3); + ok(true, "We are notified about the new location popup's target"); + + await waitFor(() => destroyedTargets.length === 1); + ok(true, "The first popup's target is destroyed"); + is( + destroyedTargets[0], + targets[1], + "The destroyed target is the popup's one" + ); + + is( + targets[2].browsingContextID, + firstPopupBrowsingContext.id, + "the new location target is for the popup" + ); + is( + targets[2].url, + POPUP_SECOND_URL, + "the new location target has the right url" + ); + + info("Close the popup"); + await SpecialPowers.spawn(firstPopupBrowsingContext, [], () => { + content.close(); + }); + + await waitFor(() => destroyedTargets.length === 2); + ok(true, "The popup's target is destroyed"); + is( + destroyedTargets[1], + targets[2], + "The destroyed target is the popup's one" + ); + + info("Open a about:blank popup"); + const aboutBlankPopupBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => { + const win = content.open("about:blank"); + return win.browsingContext; + } + ); + + await waitFor(() => targets.length === 4); + ok(true, "We are notified about the about:blank popup's target"); + + is( + targets[3].browsingContextID, + aboutBlankPopupBrowsingContext.id, + "the new target is for the popup" + ); + is(targets[3].url, "about:blank", "the new target has the right url"); + + info("Select the original tab and reload it"); + gBrowser.selectedTab = tab; + await BrowserTestUtils.reloadTab(tab); + + await waitFor(() => targets.length === 5); + is(targets[4], targetCommand.targetFront, "We get a new top level target"); + ok(!targets[3].isDestroyed(), "The about:blank popup target is still alive"); + + info("Call about:blank popup method to ensure it really is functional"); + await targets[3].logInPage("foo"); + + info( + "Ensure that iframe using window.open to load their document aren't considered as popups" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const iframe = content.document.createElement("iframe"); + iframe.setAttribute("name", "test-iframe"); + content.document.documentElement.appendChild(iframe); + content.open("data:text/html,iframe", "test-iframe"); + }); + await waitFor(() => targets.length === 6); + is( + targets[5].targetForm.isPopup, + false, + "The iframe target isn't considered as a popup" + ); + + targetCommand.unwatchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + targetCommand.destroy(); + BrowserTestUtils.removeTab(tab); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_frames_reload_server_side_targets.js b/devtools/shared/commands/target/tests/browser_target_command_frames_reload_server_side_targets.js new file mode 100644 index 0000000000..d05ff5a962 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_frames_reload_server_side_targets.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the framework handles reloading a document with multiple remote frames (See Bug 1724909). + +const REMOTE_ORIGIN = "https://example.com/"; +const REMOTE_IFRAME_URL_1 = + REMOTE_ORIGIN + "/document-builder.sjs?html=first_remote_iframe"; +const REMOTE_IFRAME_URL_2 = + REMOTE_ORIGIN + "/document-builder.sjs?html=second_remote_iframe"; +const TEST_URL = + "https://example.org/document-builder.sjs?html=org" + + `<iframe src=${REMOTE_IFRAME_URL_1}></iframe>` + + `<iframe src=${REMOTE_IFRAME_URL_2}></iframe>`; + +add_task(async function () { + // Create a TargetCommand for a given test tab + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const destroyedTargets = []; + const onAvailable = ({ targetFront }) => { + targets.push(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + destroyedTargets.push(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + await waitFor(() => targets.length === 3); + ok( + true, + "We are notified about the top-level document and the 2 remote iframes" + ); + + info("Reload the page"); + // When a new target will be created, we need to wait until it's fully processed + // to avoid pending promises. + const onNewTargetProcessed = targetCommand.once("processed-available-target"); + gBrowser.reloadTab(tab); + await onNewTargetProcessed; + + await waitFor(() => targets.length === 6 && destroyedTargets.length === 3); + + // Get the previous targets in a dedicated array and remove them from `targets` + const previousTargets = targets.splice(0, 3); + ok( + previousTargets.every(targetFront => targetFront.isDestroyed()), + "The previous targets are all destroyed" + ); + ok( + targets.every(targetFront => !targetFront.isDestroyed()), + "The new targets are not destroyed" + ); + + info("Reload one of the iframe"); + SpecialPowers.spawn(tab.linkedBrowser, [], () => { + const iframeEl = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframeEl.browsingContext, [], () => { + content.document.location.reload(); + }); + }); + await waitFor( + () => + targets.length + previousTargets.length === 7 && + destroyedTargets.length === 4 + ); + const iframeTarget = targets.find(t => t === destroyedTargets.at(-1)); + ok(iframeTarget, "Got the iframe target that got destroyed"); + for (const target of targets) { + if (target == iframeTarget) { + ok( + target.isDestroyed(), + "The iframe target we navigated from is destroyed" + ); + } else { + ok( + !target.isDestroyed(), + `Target ${target.actorID}|${target.url} isn't destroyed` + ); + } + } + + targetCommand.unwatchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + targetCommand.destroy(); + BrowserTestUtils.removeTab(tab); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_getAllTargets.js b/devtools/shared/commands/target/tests/browser_target_command_getAllTargets.js new file mode 100644 index 0000000000..a7d5e51b3c --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_getAllTargets.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API getAllTargets. + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const CHROME_WORKER_URL = CHROME_URL_ROOT + "test_worker.js"; + +add_task(async function () { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + info("Setup the test page with workers of all types"); + + const tab = await addTab(FISSION_TEST_URL); + + // Instantiate a worker in the parent process + // eslint-disable-next-line no-unused-vars + const worker = new Worker(CHROME_WORKER_URL + "#simple-worker"); + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker(CHROME_WORKER_URL + "#shared-worker"); + + info("Create a target list for the main process target"); + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + await targetCommand.startListening(); + + info("Check getAllTargets will throw when providing invalid arguments"); + Assert.throws( + () => targetCommand.getAllTargets(), + e => e.message === "getAllTargets expects a non-empty array of types" + ); + + Assert.throws( + () => targetCommand.getAllTargets([]), + e => e.message === "getAllTargets expects a non-empty array of types" + ); + + info("Check getAllTargets returns consistent results with several types"); + const workerTargets = targetCommand.getAllTargets([TYPES.WORKER]); + const serviceWorkerTargets = targetCommand.getAllTargets([ + TYPES.SERVICE_WORKER, + ]); + const sharedWorkerTargets = targetCommand.getAllTargets([ + TYPES.SHARED_WORKER, + ]); + const processTargets = targetCommand.getAllTargets([TYPES.PROCESS]); + const frameTargets = targetCommand.getAllTargets([TYPES.FRAME]); + + const allWorkerTargetsReference = [ + ...workerTargets, + ...serviceWorkerTargets, + ...sharedWorkerTargets, + ]; + const allWorkerTargets = targetCommand.getAllTargets([ + TYPES.WORKER, + TYPES.SERVICE_WORKER, + TYPES.SHARED_WORKER, + ]); + + is( + allWorkerTargets.length, + allWorkerTargetsReference.length, + "getAllTargets([worker, service, shared]) returned the expected number of targets" + ); + + ok( + allWorkerTargets.every(t => allWorkerTargetsReference.includes(t)), + "getAllTargets([worker, service, shared]) returned the expected targets" + ); + + const allTargetsReference = [ + ...allWorkerTargets, + ...processTargets, + ...frameTargets, + ]; + const allTargets = targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is( + allTargets.length, + allTargetsReference.length, + "getAllTargets(ALL_TYPES) returned the expected number of targets" + ); + + ok( + allTargets.every(t => allTargetsReference.includes(t)), + "getAllTargets(ALL_TYPES) returned the expected targets" + ); + + for (const target of allTargets) { + is( + target.commands, + commands, + "Each target front has a `commands` attribute - " + target + ); + } + + // Wait for all the targets to be fully attached so we don't have pending requests. + await waitForAllTargetsToBeAttached(targetCommand); + + ok( + !targetCommand.isDestroyed(), + "TargetCommand isn't destroyed before calling commands.destroy()" + ); + await commands.destroy(); + ok( + targetCommand.isDestroyed(), + "TargetCommand is destroyed after calling commands.destroy()" + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_invalid_api_usage.js b/devtools/shared/commands/target/tests/browser_target_command_invalid_api_usage.js new file mode 100644 index 0000000000..dbdaae7f05 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_invalid_api_usage.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test watch/unwatchTargets throw when provided with invalid types. + +const TEST_URL = "data:text/html;charset=utf-8,invalid api usage test"; + +add_task(async function () { + info("Setup the test page with workers of all types"); + const tab = await addTab(TEST_URL); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + const onAvailable = function () {}; + + await Assert.rejects( + targetCommand.watchTargets({ types: [null], onAvailable }), + /TargetCommand.watchTargets invoked with an unknown type/, + "watchTargets should throw for null type" + ); + + await Assert.rejects( + targetCommand.watchTargets({ types: [undefined], onAvailable }), + /TargetCommand.watchTargets invoked with an unknown type/, + "watchTargets should throw for undefined type" + ); + + await Assert.rejects( + targetCommand.watchTargets({ types: ["NOT_A_TARGET"], onAvailable }), + /TargetCommand.watchTargets invoked with an unknown type/, + "watchTargets should throw for unknown type" + ); + + await Assert.rejects( + targetCommand.watchTargets({ + types: [targetCommand.TYPES.FRAME, "NOT_A_TARGET"], + onAvailable, + }), + /TargetCommand.watchTargets invoked with an unknown type/, + "watchTargets should throw for unknown type mixed with a correct type" + ); + + Assert.throws( + () => targetCommand.unwatchTargets({ types: [null], onAvailable }), + /TargetCommand.unwatchTargets invoked with an unknown type/, + "unwatchTargets should throw for null type" + ); + + Assert.throws( + () => targetCommand.unwatchTargets({ types: [undefined], onAvailable }), + /TargetCommand.unwatchTargets invoked with an unknown type/, + "unwatchTargets should throw for undefined type" + ); + + Assert.throws( + () => + targetCommand.unwatchTargets({ types: ["NOT_A_TARGET"], onAvailable }), + /TargetCommand.unwatchTargets invoked with an unknown type/, + "unwatchTargets should throw for unknown type" + ); + + Assert.throws( + () => + targetCommand.unwatchTargets({ + types: [targetCommand.TYPES.CONSOLE_MESSAGE, "NOT_A_TARGET"], + onAvailable, + }), + /TargetCommand.unwatchTargets invoked with an unknown type/, + "unwatchTargets should throw for unknown type mixed with a correct type" + ); + + BrowserTestUtils.removeTab(tab); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_processes.js b/devtools/shared/commands/target/tests/browser_target_command_processes.js new file mode 100644 index 0000000000..d4f57ae036 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_processes.js @@ -0,0 +1,242 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API around processes + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`); + +add_task(async function () { + // Enabled fission's pref as the TargetCommand is almost disabled without it + await pushPref("devtools.browsertoolbox.scope", "everything"); + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + await testProcesses(targetCommand, targetCommand.targetFront); + + targetCommand.destroy(); + // Wait for all the targets to be fully attached so we don't have pending requests. + await Promise.all( + targetCommand.getAllTargets(targetCommand.ALL_TYPES).map(t => t.initialized) + ); + + await commands.destroy(); +}); + +add_task(async function () { + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const created = []; + const destroyed = []; + const onAvailable = ({ targetFront }) => { + created.push(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + destroyed.push(targetFront); + }; + await targetCommand.watchTargets({ + types: [targetCommand.TYPES.PROCESS], + onAvailable, + onDestroyed, + }); + Assert.greater(created.length, 1, "We get many content process targets"); + + targetCommand.stopListening(); + + await waitFor( + () => created.length == destroyed.length, + "Wait for the destruction of all content process targets when calling stopListening" + ); + is( + created.length, + destroyed.length, + "Got notification of destruction for all previously reported targets" + ); + + targetCommand.destroy(); + // Wait for all the targets to be fully attached so we don't have pending requests. + await Promise.all( + targetCommand.getAllTargets(targetCommand.ALL_TYPES).map(t => t.initialized) + ); + + await commands.destroy(); +}); + +async function testProcesses(targetCommand, target) { + info("Test TargetCommand against processes"); + const { TYPES } = targetCommand; + + // Note that ppmm also includes the parent process, which is considered as a frame rather than a process + const originalProcessesCount = Services.ppmm.childCount - 1; + const processes = await targetCommand.getAllTargets([TYPES.PROCESS]); + is( + processes.length, + originalProcessesCount, + "Get a target for all content processes" + ); + + const processes2 = await targetCommand.getAllTargets([TYPES.PROCESS]); + is( + processes2.length, + originalProcessesCount, + "retrieved the same number of processes" + ); + function sortFronts(f1, f2) { + return f1.actorID < f2.actorID; + } + processes.sort(sortFronts); + processes2.sort(sortFronts); + for (let i = 0; i < processes.length; i++) { + is(processes[i], processes2[i], `process ${i} targets are the same`); + } + + // Assert that watchTargets will call the create callback for all existing frames + const targets = new Set(); + + const pidRegExp = /^\d+$/; + + const onAvailable = ({ targetFront }) => { + if (targets.has(targetFront)) { + ok(false, "The same target is notified multiple times via onAvailable"); + } + is( + targetFront.targetType, + TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + ok( + pidRegExp.test(targetFront.processID), + `Target has processID of expected shape (${targetFront.processID})` + ); + targets.add(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + if (!targets.has(targetFront)) { + ok( + false, + "A target is declared destroyed via onDestroy without being notified via onAvailable" + ); + } + is( + targetFront.targetType, + TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + !targetFront.isTopLevel, + "We are never notified about the top level target destruction" + ); + targets.delete(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable, + onDestroyed, + }); + is( + targets.size, + originalProcessesCount, + "retrieved the same number of processes via watchTargets" + ); + for (let i = 0; i < processes.length; i++) { + ok( + targets.has(processes[i]), + `process ${i} targets are the same via watchTargets` + ); + } + + const previousTargets = new Set(targets); + // Assert that onAvailable is called for processes created *after* the call to watchTargets + const onProcessCreated = new Promise(resolve => { + const onAvailable2 = ({ targetFront }) => { + if (previousTargets.has(targetFront)) { + return; + } + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable2, + }); + resolve(targetFront); + }; + targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable2, + }); + }); + const tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + const createdTarget = await onProcessCreated; + // For some reason, creating a new tab purges processes created from previous tests + // so it is not reasonable to assert the size of `targets` as it may be lower than expected. + ok(targets.has(createdTarget), "The new tab process is in the list"); + + const processCountAfterTabOpen = targets.size; + + // Assert that onDestroy is called for destroyed processes + const onProcessDestroyed = new Promise(resolve => { + const onAvailable3 = () => {}; + const onDestroyed3 = ({ targetFront }) => { + resolve(targetFront); + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable3, + onDestroyed: onDestroyed3, + }); + }; + targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable3, + onDestroyed: onDestroyed3, + }); + }); + + BrowserTestUtils.removeTab(tab1); + + const destroyedTarget = await onProcessDestroyed; + is( + targets.size, + processCountAfterTabOpen - 1, + "The closed tab's process has been reported as destroyed" + ); + ok( + !targets.has(destroyedTarget), + "The destroyed target is no longer in the list" + ); + is( + destroyedTarget, + createdTarget, + "The destroyed target is the one that has been reported as created" + ); + + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable, + onDestroyed, + }); + + // Ensure that getAllTargets still works after the call to unwatchTargets + const processes3 = await targetCommand.getAllTargets([TYPES.PROCESS]); + is( + processes3.length, + processCountAfterTabOpen - 1, + "getAllTargets reports a new target" + ); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_reload.js b/devtools/shared/commands/target/tests/browser_target_command_reload.js new file mode 100644 index 0000000000..9d8cacd23d --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_reload.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand's reload method +// +// Note that we reload against main process, +// but this is hard/impossible to test as it reloads the test script itself +// and so stops its execution. + +// Load a page with a JS script that change its value everytime we load it +// (that's to see if the reload loads from cache or not) +const TEST_URL = URL_ROOT + "incremental-js-value-script.sjs"; + +add_task(async function () { + info(" ### Test reloading a Tab"); + + // Create a TargetCommand for a given test tab + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + // We have to start listening in order to ensure having a targetFront available + await targetCommand.startListening(); + + const firstJSValue = await getContentVariable(); + is(firstJSValue, "1", "Got an initial value for the JS variable"); + + const onReloaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await targetCommand.reloadTopLevelTarget(); + info("Wait for the tab to be reloaded"); + await onReloaded; + + const secondJSValue = await getContentVariable(); + is( + secondJSValue, + "1", + "The first reload didn't bypass the cache, so the JS Script is the same and we got the same value" + ); + + const onSecondReloaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + await targetCommand.reloadTopLevelTarget(true); + info("Wait for the tab to be reloaded"); + await onSecondReloaded; + + // The value is 3 and not 2, because we got a HTTP request, but it returned 304 and the browser fetched his cached content + const thirdJSValue = await getContentVariable(); + is( + thirdJSValue, + "3", + "The second reload did bypass the cache, so the JS Script is different and we got a new value" + ); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +}); + +add_task(async function () { + info(" ### Test reloading an Add-on"); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background() { + const { browser } = this; + browser.test.log("background script executed"); + }, + }); + + await extension.startup(); + + const commands = await CommandsFactory.forAddon(extension.id); + const targetCommand = commands.targetCommand; + + // We have to start listening in order to ensure having a targetFront available + await targetCommand.startListening(); + + const { onResource: onReloaded } = + await commands.resourceCommand.waitForNextResource( + commands.resourceCommand.TYPES.DOCUMENT_EVENT, + { + ignoreExistingResources: true, + predicate(resource) { + return resource.name == "dom-loading"; + }, + } + ); + + const backgroundPageURL = targetCommand.targetFront.url; + ok(backgroundPageURL, "Got the background page URL"); + await targetCommand.reloadTopLevelTarget(); + + info("Wait for next dom-loading DOCUMENT_EVENT"); + const event = await onReloaded; + + // If we get about:blank here, it most likely means we receive notification + // for the previous background page being unload and navigating to about:blank + is( + event.url, + backgroundPageURL, + "We received the DOCUMENT_EVENT's for the expected document: the new background page." + ); + + await commands.destroy(); + + await extension.unload(); +}); +function getContentVariable() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + return content.wrappedJSObject.jsValue; + }); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_scope_flag.js b/devtools/shared/commands/target/tests/browser_target_command_scope_flag.js new file mode 100644 index 0000000000..65d9e9a622 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_scope_flag.js @@ -0,0 +1,190 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API with changes made to devtools.browsertoolbox.scope + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`); + +add_task(async function () { + // Do not run this test when both fission and EFT is disabled as it changes + // the number of targets + if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) { + return; + } + + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + // First test with multiprocess debugging enabled + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + const { TYPES } = targetCommand; + + const targets = new Set(); + const destroyedTargetIsModeSwitchingMap = new Map(); + const onAvailable = async ({ targetFront }) => { + targets.add(targetFront); + }; + const onDestroyed = ({ targetFront, isModeSwitching }) => { + destroyedTargetIsModeSwitchingMap.set(targetFront, isModeSwitching); + targets.delete(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.PROCESS, TYPES.FRAME], + onAvailable, + onDestroyed, + }); + Assert.greater(targets.size, 1, "We get many targets"); + + info("Open a tab in a new content process"); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + + const newTabProcessID = + gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal + .osPid; + const newTabInnerWindowId = + gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal + .innerWindowId; + + info("Wait for the tab content process target"); + const processTarget = await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.PROCESS && + target.processID == newTabProcessID + ) + ); + + info("Wait for the tab window global target"); + const windowGlobalTarget = await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.FRAME && + target.innerWindowId == newTabInnerWindowId + ) + ); + + let multiprocessTargetCount = targets.size; + + info("Disable multiprocess debugging"); + await pushPref("devtools.browsertoolbox.scope", "parent-process"); + + info("Wait for all targets but top level and workers to be destroyed"); + await waitFor(() => + [...targets].every( + target => + target == targetCommand.targetFront || target.targetType == TYPES.WORKER + ) + ); + + ok(processTarget.isDestroyed(), "The process target is destroyed"); + ok( + destroyedTargetIsModeSwitchingMap.get(processTarget), + "isModeSwitching was passed to onTargetDestroyed and is true for the process target" + ); + ok(windowGlobalTarget.isDestroyed(), "The window global target is destroyed"); + ok( + destroyedTargetIsModeSwitchingMap.get(windowGlobalTarget), + "isModeSwitching was passed to onTargetDestroyed and is true for the window global target" + ); + + info("Open a second tab in a new content process"); + const parentProcessTargetCount = targets.size; + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + + await wait(1000); + is( + parentProcessTargetCount, + targets.size, + "The new tab process should be ignored and no target be created" + ); + + info("Re-enable multiprocess debugging"); + await pushPref("devtools.browsertoolbox.scope", "everything"); + + // The second tab relates to one content process target and one window global target + multiprocessTargetCount += 2; + + await waitFor( + () => targets.size == multiprocessTargetCount, + "Wait for all targets we used to have before disable multiprocess debugging" + ); + + info("Wait for the tab content process target to be available again"); + ok( + [...targets].some( + target => + target.targetType == TYPES.PROCESS && + target.processID == newTabProcessID + ), + "We have the tab content process target" + ); + + info("Wait for the tab window global target to be available again"); + ok( + [...targets].some( + target => + target.targetType == TYPES.FRAME && + target.innerWindowId == newTabInnerWindowId + ), + "We have the tab window global target" + ); + + info("Open a third tab in a new content process"); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + + const thirdTabProcessID = + gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal + .osPid; + const thirdTabInnerWindowId = + gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal + .innerWindowId; + + info("Wait for the third tab content process target"); + await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.PROCESS && + target.processID == thirdTabProcessID + ) + ); + + info("Wait for the third tab window global target"); + await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.FRAME && + target.innerWindowId == thirdTabInnerWindowId + ) + ); + + targetCommand.destroy(); + + // Wait for all the targets to be fully attached so we don't have pending requests. + await Promise.all( + targetCommand.getAllTargets(targetCommand.ALL_TYPES).map(t => t.initialized) + ); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_service_workers.js b/devtools/shared/commands/target/tests/browser_target_command_service_workers.js new file mode 100644 index 0000000000..d71401fd8c --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_service_workers.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API for service workers in content tabs. + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; + +add_task(async function () { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + info("Setup the test page with workers of all types"); + + const tab = await addTab(FISSION_TEST_URL); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + // Enable Service Worker listening. + targetCommand.listenForServiceWorkers = true; + await targetCommand.startListening(); + + const serviceWorkerTargets = targetCommand.getAllTargets([ + TYPES.SERVICE_WORKER, + ]); + is( + serviceWorkerTargets.length, + 1, + "TargetCommmand has 1 service worker target" + ); + + info("Check that the onAvailable is done when watchTargets resolves"); + const targets = []; + const onAvailable = async ({ targetFront }) => { + // Wait for one second here to check that watch targets waits for + // the onAvailable callbacks correctly. + await wait(1000); + targets.push(targetFront); + }; + const onDestroyed = ({ targetFront }) => + targets.splice(targets.indexOf(targetFront), 1); + + await targetCommand.watchTargets({ + types: [TYPES.SERVICE_WORKER], + onAvailable, + onDestroyed, + }); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + is(targets.length, 1, "onAvailable has resolved"); + is( + targets[0], + serviceWorkerTargets[0], + "onAvailable was called with the expected service worker target" + ); + + info("Unregister the worker and wait until onDestroyed is called."); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); + await waitUntil(() => targets.length === 0); + + // Stop listening to avoid worker related requests + targetCommand.destroy(); + + await commands.waitForRequestsToSettle(); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js b/devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js new file mode 100644 index 0000000000..7bf6c856c2 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js @@ -0,0 +1,358 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API for service workers when navigating in content tabs. +// When the top level target navigates, we manually call onTargetAvailable for +// service workers which now match the page domain. We assert that the callbacks +// will be called the expected number of times here. + +const COM_PAGE_URL = URL_ROOT_SSL + "test_sw_page.html"; +const COM_WORKER_URL = URL_ROOT_SSL + "test_sw_page_worker.js"; +const ORG_PAGE_URL = URL_ROOT_ORG_SSL + "test_sw_page.html"; +const ORG_WORKER_URL = URL_ROOT_ORG_SSL + "test_sw_page_worker.js"; + +/** + * This test will navigate between two pages, both controlled by different + * service workers. + * + * The steps will be: + * - navigate to .com page + * - create target list + * -> onAvailable should be called for the .com worker + * - navigate to .org page + * -> onAvailable should be called for the .org worker + * - reload .org page + * -> nothing should happen + * - unregister .org worker + * -> onDestroyed should be called for the .org worker + * - navigate back to .com page + * -> nothing should happen + * - unregister .com worker + * -> onDestroyed should be called for the .com worker + */ +add_task(async function test_NavigationBetweenTwoDomains_NoDestroy() { + await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + const { hooks, commands, targetCommand } = await watchServiceWorkerTargets( + tab + ); + + // 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.startLoadingURIString( + gBrowser.selectedBrowser, + ORG_PAGE_URL + ); + await checkHooks(hooks, { + available: 2, + destroyed: 0, + targets: [COM_WORKER_URL, ORG_WORKER_URL], + }); + + info("Reload .org page, onAvailable and onDestroyed should not be called"); + await BrowserTestUtils.reloadTab(gBrowser.selectedTab); + await checkHooks(hooks, { + available: 2, + destroyed: 0, + targets: [COM_WORKER_URL, ORG_WORKER_URL], + }); + + info("Unregister .org service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(ORG_WORKER_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [COM_WORKER_URL], + }); + + info("Go back to .com page"); + const onBrowserLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + COM_PAGE_URL + ); + await onBrowserLoaded; + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [COM_WORKER_URL], + }); + + info("Unregister .com service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(COM_WORKER_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 2, + targets: [], + }); + + // Stop listening to avoid worker related requests + targetCommand.destroy(); + + await commands.waitForRequestsToSettle(); + await commands.destroy(); + await removeTab(tab); +}); + +/** + * In this test we load a service worker in a page prior to starting the + * TargetCommand. We start the target list on another page, and then we go back to + * the first page. We want to check that we are correctly notified about the + * worker that was spawned before TargetCommand. + * + * Steps: + * - navigate to .com page + * - navigate to .org page + * - create target list + * -> 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() { + await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + info("Wait until the service worker registration is registered"); + await waitForRegistrationReady(tab, COM_PAGE_URL, COM_WORKER_URL); + + info("Navigate to another page"); + let onBrowserLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + ORG_PAGE_URL + ); + + // Avoid TV failures, where target list still starts thinking that the + // current domain is .com . + info("Wait until we have fully navigated to the .org page"); + // wait for the browser to be loaded otherwise the task spawned in waitForRegistrationReady + // might be destroyed (when it still belongs to the previous content process) + await onBrowserLoaded; + await waitForRegistrationReady(tab, ORG_PAGE_URL, ORG_WORKER_URL); + + const { hooks, commands, targetCommand } = await watchServiceWorkerTargets( + tab + ); + + // 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(ORG_WORKER_URL); + await checkHooks(hooks, { + available: 1, + destroyed: 1, + targets: [], + }); + + info("Go back .com page, wait for onAvailable to be called"); + onBrowserLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + COM_PAGE_URL + ); + await onBrowserLoaded; + + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [COM_WORKER_URL], + }); + + info("Unregister .com service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(COM_WORKER_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 2, + targets: [], + }); + + // Stop listening to avoid worker related requests + targetCommand.destroy(); + + await commands.waitForRequestsToSettle(); + await commands.destroy(); + await removeTab(tab); +}); + +add_task(async function test_NavigationToPageWithExistingStoppedWorker() { + await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + info("Wait until the service worker registration is registered"); + await waitForRegistrationReady(tab, COM_PAGE_URL, COM_WORKER_URL); + + await stopServiceWorker(COM_WORKER_URL); + + const { hooks, commands, targetCommand } = await watchServiceWorkerTargets( + tab + ); + + // Let some time to watch target to eventually regress and revive the worker + await wait(1000); + + // As the Service Worker doesn't have any active worker... it doesn't report any target. + info( + "Verify that no SW is reported after it has been stopped and we start watching for service workers" + ); + await checkHooks(hooks, { + available: 0, + destroyed: 0, + targets: [], + }); + + info("Reload the worker module via the postMessage call"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const registration = await content.wrappedJSObject.registrationPromise; + // Force loading the worker again, even it has been stopped + registration.active.postMessage(""); + }); + + info("Verify that the SW is notified"); + await checkHooks(hooks, { + available: 1, + destroyed: 0, + targets: [COM_WORKER_URL], + }); + + await unregisterServiceWorker(COM_WORKER_URL); + + await checkHooks(hooks, { + available: 1, + destroyed: 1, + targets: [], + }); + + // Stop listening to avoid worker related requests + targetCommand.destroy(); + + await commands.waitForRequestsToSettle(); + await commands.destroy(); + await removeTab(tab); +}); + +async function setupServiceWorkerNavigationTest() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); +} + +async function watchServiceWorkerTargets(tab) { + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + // Enable Service Worker listening. + targetCommand.listenForServiceWorkers = true; + await targetCommand.startListening(); + + // Setup onAvailable & onDestroyed callbacks so that we can check how many + // times they are called and with which targetFront. + const hooks = { + availableCount: 0, + destroyedCount: 0, + targets: [], + }; + + const onAvailable = async ({ targetFront }) => { + info(` + Service worker target available for ${targetFront.url}\n`); + hooks.availableCount++; + hooks.targets.push(targetFront); + }; + + const onDestroyed = ({ targetFront }) => { + info(` - Service worker target destroy for ${targetFront.url}\n`); + hooks.destroyedCount++; + hooks.targets.splice(hooks.targets.indexOf(targetFront), 1); + }; + + await targetCommand.watchTargets({ + types: [targetCommand.TYPES.SERVICE_WORKER], + onAvailable, + onDestroyed, + }); + + return { hooks, commands, targetCommand }; +} + +/** + * Wait until the expected URL is loaded and win.registration has resolved. + */ +async function waitForRegistrationReady(tab, expectedPageUrl, workerUrl) { + 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; + } + }) + ); + // On debug builds, the registration may not be yet ready in the parent process + // so we also need to ensure it is ready. + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + await waitFor(() => { + // Unfortunately we can't use swm.getRegistrationByPrincipal, as it requires a "scope", which doesn't seem to be the worker URL. + const registrations = swm.getAllRegistrations(); + for (let i = 0; i < registrations.length; i++) { + const info = registrations.queryElementAt( + i, + Ci.nsIServiceWorkerRegistrationInfo + ); + // Lookup for an exact URL match. + if (info.scriptSpec === workerUrl) { + return true; + } + } + 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 }) { + await waitUntil( + () => hooks.availableCount == available && hooks.destroyedCount == destroyed + ); + is(hooks.availableCount, available, "onAvailable was called as expected"); + is(hooks.destroyedCount, destroyed, "onDestroyed was called as expected"); + + is(hooks.targets.length, targets.length, "Expected number of targets"); + targets.forEach((url, i) => { + is(hooks.targets[i].url, url, `SW target ${i} has the expected url`); + }); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_switchToTarget.js b/devtools/shared/commands/target/tests/browser_target_command_switchToTarget.js new file mode 100644 index 0000000000..04646117a9 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_switchToTarget.js @@ -0,0 +1,138 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API switchToTarget function + +add_task(async function testSwitchToTarget() { + info("Test TargetCommand.switchToTarget method"); + + // Create a first target to switch from, a new tab with an iframe + const firstTab = await addTab( + `data:text/html,<iframe src="data:text/html,foo"></iframe>` + ); + const commands = await CommandsFactory.forTab(firstTab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + await targetCommand.startListening(); + + // Create a second target to switch to, a new tab with an iframe + const secondTab = await addTab( + `data:text/html,<iframe src="data:text/html,bar"></iframe>` + ); + // We have to spawn a new distinct `commands` object for this new tab, + // but we will otherwise consider the first one as the main one. + // From this second one, we will only retrieve a new target. + const secondCommands = await CommandsFactory.forTab(secondTab, { + client: commands.client, + }); + await secondCommands.targetCommand.startListening(); + const secondTarget = secondCommands.targetCommand.targetFront; + + const frameTargets = []; + const firstTarget = targetCommand.targetFront; + let currentTarget = targetCommand.targetFront; + const onFrameAvailable = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront == currentTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + if (targetFront.isTopLevel) { + // When calling watchTargets, this will be false, but it will be true when calling switchToTarget + is( + isTargetSwitching, + currentTarget == secondTarget, + "target switching boolean is correct" + ); + } else { + ok(!isTargetSwitching, "for now, only top level target can be switched"); + } + frameTargets.push(targetFront); + }; + const destroyedTargets = []; + const onFrameDestroyed = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "target-destroyed: We are only notified about frame targets" + ); + ok( + targetFront == firstTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "target-destroyed: isTopLevel property is correct" + ); + if (targetFront.isTopLevel) { + is( + isTargetSwitching, + true, + "target-destroyed: target switching boolean is correct" + ); + } else { + ok( + !isTargetSwitching, + "target-destroyed: for now, only top level target can be switched" + ); + } + destroyedTargets.push(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable: onFrameAvailable, + onDestroyed: onFrameDestroyed, + }); + + // Save the original list of targets + const createdTargets = [...frameTargets]; + // Clear the recorded target list of all existing targets + frameTargets.length = 0; + + currentTarget = secondTarget; + await targetCommand.switchToTarget(secondTarget); + + is( + targetCommand.targetFront, + currentTarget, + "After the switch, the top level target has been updated" + ); + // Because JS Window Actor API isn't used yet, FrameDescriptor.getTarget returns null + // And there is no target being created for the iframe, yet. + // As soon as bug 1565200 is resolved, this should return two frames, including the iframe. + is( + frameTargets.length, + 1, + "We get the report of the top level iframe when switching to the new target" + ); + is(frameTargets[0], currentTarget); + //is(frameTargets[1].url, "data:text/html,foo"); + + // Ensure that all the targets reported before the call to switchToTarget + // are reported as destroyed while calling switchToTarget. + is( + destroyedTargets.length, + createdTargets.length, + "All targets original reported are destroyed" + ); + for (const newTarget of createdTargets) { + ok( + destroyedTargets.includes(newTarget), + "Each originally target is reported as destroyed" + ); + } + + targetCommand.destroy(); + + await commands.destroy(); + await secondCommands.destroy(); + + BrowserTestUtils.removeTab(firstTab); + BrowserTestUtils.removeTab(secondTab); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_tab_workers.js b/devtools/shared/commands/target/tests/browser_target_command_tab_workers.js new file mode 100644 index 0000000000..92f5629d4c --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_tab_workers.js @@ -0,0 +1,322 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API around workers + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const IFRAME_FILE = "fission_iframe.html"; +const REMOTE_IFRAME_URL = URL_ROOT_ORG_SSL + IFRAME_FILE; +const IFRAME_URL = URL_ROOT_SSL + IFRAME_FILE; +const WORKER_FILE = "test_worker.js"; +const WORKER_URL = URL_ROOT_SSL + WORKER_FILE; +const REMOTE_IFRAME_WORKER_URL = URL_ROOT_ORG_SSL + WORKER_FILE; + +add_task(async function () { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + // The WorkerDebuggerManager#getWorkerDebuggerEnumerator method we're using to retrieve + // workers loops through _all_ the workers in the process, which means it goes over workers + // from other tabs as well. Here we add a few tabs that are not going to be used in the + // test, just to check that their workers won't be retrieved by getAllTargets/watchTargets. + await addTab(`${FISSION_TEST_URL}?id=first-untargetted-tab&noServiceWorker`); + await addTab(`${FISSION_TEST_URL}?id=second-untargetted-tab&noServiceWorker`); + + info("Test TargetCommand against workers via a tab target"); + const tab = await addTab(`${FISSION_TEST_URL}?&noServiceWorker`); + + // Create a TargetCommand for the tab + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + // Workaround to allow listening for workers in the content toolbox + // without the fission preferences + targetCommand.listenForWorkers = true; + + await commands.targetCommand.startListening(); + + const { TYPES } = targetCommand; + + info("Check that getAllTargets only returns dedicated workers"); + const workers = await targetCommand.getAllTargets([ + TYPES.WORKER, + TYPES.SHARED_WORKER, + ]); + + // XXX: This should be modified in Bug 1607778, where we plan to add support for shared workers. + is(workers.length, 2, "Retrieved two worker…"); + const mainPageWorker = workers.find( + worker => worker.url == `${WORKER_URL}#simple-worker` + ); + const iframeWorker = workers.find(worker => { + return worker.url == `${REMOTE_IFRAME_WORKER_URL}#simple-worker-in-iframe`; + }); + ok(mainPageWorker, "…the dedicated worker on the main page"); + ok(iframeWorker, "…and the dedicated worker on the iframe"); + + info( + "Assert that watchTargets will call the create callback for existing dedicated workers" + ); + const targets = []; + const destroyedTargets = []; + const onAvailable = async ({ targetFront }) => { + info(`onAvailable called for ${targetFront.url}`); + is( + targetFront.targetType, + TYPES.WORKER, + "We are only notified about worker targets" + ); + ok(!targetFront.isTopLevel, "The workers are never top level"); + targets.push(targetFront); + info(`Handled ${targets.length} targets\n`); + }; + const onDestroyed = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.WORKER, + "We are only notified about worker targets" + ); + ok(!targetFront.isTopLevel, "The workers are never top level"); + destroyedTargets.push(targetFront); + }; + + await targetCommand.watchTargets({ + types: [TYPES.WORKER, TYPES.SHARED_WORKER], + onAvailable, + onDestroyed, + }); + + // XXX: This should be modified in Bug 1607778, where we plan to add support for shared workers. + info("Check that watched targets return the same fronts as getAllTargets"); + is(targets.length, 2, "watcheTargets retrieved 2 worker…"); + const mainPageWorkerTarget = targets.find(t => t === mainPageWorker); + const iframeWorkerTarget = targets.find(t => t === iframeWorker); + + ok( + mainPageWorkerTarget, + "…the dedicated worker in main page, which is the same front we received from getAllTargets" + ); + ok( + iframeWorkerTarget, + "…the dedicated worker in iframe, which is the same front we received from getAllTargets" + ); + + info("Spawn workers in main page and iframe"); + await SpecialPowers.spawn(tab.linkedBrowser, [WORKER_FILE], workerUrl => { + // Put the worker on the global so we can access it later + content.spawnedWorker = new content.Worker(`${workerUrl}#spawned-worker`); + const iframe = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframe, [workerUrl], innerWorkerUrl => { + // Put the worker on the global so we can access it later + content.spawnedWorker = new content.Worker( + `${innerWorkerUrl}#spawned-worker-in-iframe` + ); + }); + }); + + await waitFor( + () => targets.length === 4, + "Wait for the target list to notify us about the spawned worker" + ); + const mainPageSpawnedWorkerTarget = targets.find( + innerTarget => innerTarget.url == `${WORKER_URL}#spawned-worker` + ); + ok(mainPageSpawnedWorkerTarget, "Retrieved spawned worker"); + const iframeSpawnedWorkerTarget = targets.find( + innerTarget => + innerTarget.url == `${REMOTE_IFRAME_WORKER_URL}#spawned-worker-in-iframe` + ); + ok(iframeSpawnedWorkerTarget, "Retrieved spawned worker in iframe"); + + await wait(100); + + info( + "Check that the target list calls onDestroy when a worker is terminated" + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.spawnedWorker.terminate(); + content.spawnedWorker = null; + + SpecialPowers.spawn(content.document.querySelector("iframe"), [], () => { + content.spawnedWorker.terminate(); + content.spawnedWorker = null; + }); + }); + await waitFor( + () => + destroyedTargets.includes(mainPageSpawnedWorkerTarget) && + destroyedTargets.includes(iframeSpawnedWorkerTarget), + "Wait for the target list to notify us about the terminated workers" + ); + + ok( + true, + "The target list handled the terminated workers (from the main page and the iframe)" + ); + + info( + "Check that reloading the page will notify about the terminated worker and the new existing one" + ); + const targetsCountBeforeReload = targets.length; + await reloadBrowser(); + + await waitFor(() => { + return ( + destroyedTargets.includes(mainPageWorkerTarget) && + destroyedTargets.includes(iframeWorkerTarget) + ); + }, `Wait for the target list to notify us about the terminated workers when reloading`); + ok( + true, + "The target list notified us about all the expected workers being destroyed when reloading" + ); + + await waitFor( + () => targets.length === targetsCountBeforeReload + 2, + "Wait for the target list to notify us about the new workers after reloading" + ); + + const mainPageWorkerTargetAfterReload = targets.find( + t => t !== mainPageWorkerTarget && t.url == `${WORKER_URL}#simple-worker` + ); + const iframeWorkerTargetAfterReload = targets.find( + t => + t !== iframeWorkerTarget && + t.url == `${REMOTE_IFRAME_WORKER_URL}#simple-worker-in-iframe` + ); + + ok( + mainPageWorkerTargetAfterReload, + "The target list handled the worker created once the page navigated" + ); + ok( + iframeWorkerTargetAfterReload, + "The target list handled the worker created in the iframe once the page navigated" + ); + + const targetCount = targets.length; + + info( + "Check that when removing an iframe we're notified about its workers being terminated" + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.document.querySelector("iframe").remove(); + }); + await waitFor(() => { + return destroyedTargets.includes(iframeWorkerTargetAfterReload); + }, `Wait for the target list to notify us about the terminated workers when removing an iframe`); + + info("Check that target list handles adding iframes with workers"); + const iframeUrl = `${IFRAME_URL}?noServiceWorker=true&hashSuffix=in-created-iframe`; + const remoteIframeUrl = `${REMOTE_IFRAME_URL}?noServiceWorker=true&hashSuffix=in-created-remote-iframe`; + + await SpecialPowers.spawn( + tab.linkedBrowser, + [iframeUrl, remoteIframeUrl], + (url, remoteUrl) => { + const firstIframe = content.document.createElement("iframe"); + content.document.body.append(firstIframe); + firstIframe.src = url + "-1"; + + const secondIframe = content.document.createElement("iframe"); + content.document.body.append(secondIframe); + secondIframe.src = url + "-2"; + + const firstRemoteIframe = content.document.createElement("iframe"); + content.document.body.append(firstRemoteIframe); + firstRemoteIframe.src = remoteUrl + "-1"; + + const secondRemoteIframe = content.document.createElement("iframe"); + content.document.body.append(secondRemoteIframe); + secondRemoteIframe.src = remoteUrl + "-2"; + } + ); + + // It's important to check the length of `targets` here to ensure we don't get unwanted + // worker targets. + await waitFor( + () => targets.length === targetCount + 4, + "Wait for the target list to notify us about the workers in the new iframes" + ); + const firstSpawnedIframeWorkerTarget = targets.find( + worker => worker.url == `${WORKER_URL}#simple-worker-in-created-iframe-1` + ); + const secondSpawnedIframeWorkerTarget = targets.find( + worker => worker.url == `${WORKER_URL}#simple-worker-in-created-iframe-2` + ); + const firstSpawnedRemoteIframeWorkerTarget = targets.find( + worker => + worker.url == + `${REMOTE_IFRAME_WORKER_URL}#simple-worker-in-created-remote-iframe-1` + ); + const secondSpawnedRemoteIframeWorkerTarget = targets.find( + worker => + worker.url == + `${REMOTE_IFRAME_WORKER_URL}#simple-worker-in-created-remote-iframe-2` + ); + + ok( + firstSpawnedIframeWorkerTarget, + "The target list handled the worker in the first new same-origin iframe" + ); + ok( + secondSpawnedIframeWorkerTarget, + "The target list handled the worker in the second new same-origin iframe" + ); + ok( + firstSpawnedRemoteIframeWorkerTarget, + "The target list handled the worker in the first new remote iframe" + ); + ok( + secondSpawnedRemoteIframeWorkerTarget, + "The target list handled the worker in the second new remote iframe" + ); + + info("Check that navigating away does destroy all targets"); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "data:text/html,<meta charset=utf8>Away" + ); + + await waitFor( + () => destroyedTargets.length === targets.length, + "Wait for all the targets to be reported as destroyed" + ); + + ok( + destroyedTargets.includes(mainPageWorkerTargetAfterReload), + "main page worker target was destroyed" + ); + ok( + destroyedTargets.includes(firstSpawnedIframeWorkerTarget), + "first spawned same-origin iframe worker target was destroyed" + ); + ok( + destroyedTargets.includes(secondSpawnedIframeWorkerTarget), + "second spawned same-origin iframe worker target was destroyed" + ); + ok( + destroyedTargets.includes(firstSpawnedRemoteIframeWorkerTarget), + "first spawned remote iframe worker target was destroyed" + ); + ok( + destroyedTargets.includes(secondSpawnedRemoteIframeWorkerTarget), + "second spawned remote iframe worker target was destroyed" + ); + + targetCommand.unwatchTargets({ + types: [TYPES.WORKER, TYPES.SHARED_WORKER], + onAvailable, + onDestroyed, + }); + targetCommand.destroy(); + + info("Unregister service workers so they don't appear in other tests."); + await unregisterAllServiceWorkers(commands.client); + + BrowserTestUtils.removeTab(tab); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_tab_workers_bfcache_navigation.js b/devtools/shared/commands/target/tests/browser_target_command_tab_workers_bfcache_navigation.js new file mode 100644 index 0000000000..e628f827e2 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_tab_workers_bfcache_navigation.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test WORKER targets when doing history navigations (BF Cache) +// +// Use a distinct file as this test currently hits a DEBUG assertion +// https://searchfox.org/mozilla-central/rev/352b525ab841278cd9b3098343f655ef85933544/dom/workers/WorkerPrivate.cpp#5218 +// and so is running only on OPT builds. + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const WORKER_FILE = "test_worker.js"; +const WORKER_URL = URL_ROOT_SSL + WORKER_FILE; +const IFRAME_WORKER_URL = URL_ROOT_ORG_SSL + WORKER_FILE; + +add_task(async function () { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + // The WorkerDebuggerManager#getWorkerDebuggerEnumerator method we're using to retrieve + // workers loops through _all_ the workers in the process, which means it goes over workers + // from other tabs as well. Here we add a few tabs that are not going to be used in the + // test, just to check that their workers won't be retrieved by getAllTargets/watchTargets. + await addTab(`${FISSION_TEST_URL}?id=first-untargetted-tab&noServiceWorker`); + await addTab(`${FISSION_TEST_URL}?id=second-untargetted-tab&noServiceWorker`); + + info("Test bfcache navigations"); + const tab = await addTab(`${FISSION_TEST_URL}?&noServiceWorker`); + + // Create a TargetCommand for the tab + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + // Workaround to allow listening for workers in the content toolbox + // without the fission preferences + targetCommand.listenForWorkers = true; + + await targetCommand.startListening(); + + const { TYPES } = targetCommand; + + info( + "Assert that watchTargets will call the onAvailable callback for existing dedicated workers" + ); + const targets = []; + const destroyedTargets = []; + const onAvailable = async ({ targetFront }) => { + info(`onAvailable called for ${targetFront.url}`); + is( + targetFront.targetType, + TYPES.WORKER, + "We are only notified about worker targets" + ); + ok(!targetFront.isTopLevel, "The workers are never top level"); + targets.push(targetFront); + info(`Handled ${targets.length} new targets`); + }; + const onDestroyed = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.WORKER, + "We are only notified about worker targets" + ); + ok(!targetFront.isTopLevel, "The workers are never top level"); + destroyedTargets.push(targetFront); + }; + + await targetCommand.watchTargets({ + types: [TYPES.WORKER, TYPES.SHARED_WORKER], + onAvailable, + onDestroyed, + }); + + is(targets.length, 2, "watchTargets retrieved 2 workers…"); + const mainPageWorkerTarget = targets.find( + worker => worker.url == `${WORKER_URL}#simple-worker` + ); + const iframeWorkerTarget = targets.find( + worker => worker.url == `${IFRAME_WORKER_URL}#simple-worker-in-iframe` + ); + + ok( + mainPageWorkerTarget, + "…the dedicated worker in main page, which is the same front we received from getAllTargets" + ); + ok( + iframeWorkerTarget, + "…the dedicated worker in iframe, which is the same front we received from getAllTargets" + ); + + info("Check that navigating away does destroy all targets"); + const onBrowserLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "data:text/html,<meta charset=utf8>Away" + ); + await onBrowserLoaded; + + await waitFor( + () => destroyedTargets.length === 2, + "Wait for all the targets to be reported as destroyed" + ); + + info("Navigate back to the first page"); + gBrowser.goBack(); + + await waitFor( + () => targets.length === 4, + "Wait for the target list to notify us about the first page workers, restored from the BF Cache" + ); + + const mainPageWorkerTargetAfterGoingBack = targets.find( + t => t !== mainPageWorkerTarget && t.url == `${WORKER_URL}#simple-worker` + ); + const iframeWorkerTargetAfterGoingBack = targets.find( + t => + t !== iframeWorkerTarget && + t.url == `${IFRAME_WORKER_URL}#simple-worker-in-iframe` + ); + + ok( + mainPageWorkerTargetAfterGoingBack, + "The target list handled the worker created from the BF Cache" + ); + ok( + iframeWorkerTargetAfterGoingBack, + "The target list handled the worker created in the iframe from the BF Cache" + ); + + targetCommand.destroy(); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js b/devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js new file mode 100644 index 0000000000..4ee5dd8b2f --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js @@ -0,0 +1,284 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API with all possible descriptors + +const TEST_URL = "https://example.org/document-builder.sjs?html=org"; +const SECOND_TEST_URL = "https://example.com/document-builder.sjs?html=org"; +const CHROME_WORKER_URL = CHROME_URL_ROOT + "test_worker.js"; + +const DESCRIPTOR_TYPES = require("resource://devtools/client/fronts/descriptors/descriptor-types.js"); + +add_task(async function () { + // Enabled fission prefs + await pushPref("devtools.browsertoolbox.scope", "everything"); + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + await testLocalTab(); + await testRemoteTab(); + await testParentProcess(); + await testWorker(); + await testWebExtension(); +}); + +async function testParentProcess() { + info("Test TargetCommand against parent process descriptor"); + + const commands = await CommandsFactory.forMainProcess(); + const { descriptorFront } = commands; + + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.PROCESS, + "The descriptor type is correct" + ); + is( + descriptorFront.isParentProcessDescriptor, + true, + "Descriptor front isParentProcessDescriptor is correct" + ); + is( + descriptorFront.isProcessDescriptor, + true, + "Descriptor front isProcessDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + Assert.greater( + targets.length, + 1, + "We get many targets when debugging the parent process" + ); + const targetFront = targets[0]; + is(targetFront, targetCommand.targetFront, "The first is the top level one"); + is( + targetFront.targetType, + targetCommand.TYPES.FRAME, + "the parent process target is of frame type, because it inherits from WindowGlobalTargetActor" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + targetCommand.destroy(); + await waitForAllTargetsToBeAttached(targetCommand); + + await commands.destroy(); +} + +async function testLocalTab() { + info("Test TargetCommand against local tab descriptor (via getTab({ tab }))"); + + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const { descriptorFront } = commands; + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.TAB, + "The descriptor type is correct" + ); + is( + descriptorFront.isTabDescriptor, + true, + "Descriptor front isTabDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is(targets.length, 1, "Got a unique target"); + const targetFront = targets[0]; + is(targetFront, targetCommand.targetFront, "The first is the top level one"); + is( + targetFront.targetType, + targetCommand.TYPES.FRAME, + "the tab target is of frame type" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + targetCommand.destroy(); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} + +async function testRemoteTab() { + info( + "Test TargetCommand against remote tab descriptor (via getTab({ browserId }))" + ); + + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forRemoteTab( + tab.linkedBrowser.browserId + ); + const { descriptorFront } = commands; + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.TAB, + "The descriptor type is correct" + ); + is( + descriptorFront.isTabDescriptor, + true, + "Descriptor front isTabDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is(targets.length, 1, "Got a unique target"); + const targetFront = targets[0]; + is( + targetFront, + targetCommand.targetFront, + "TargetCommand top target is the same as the first target" + ); + is( + targetFront.targetType, + targetCommand.TYPES.FRAME, + "the tab target is of frame type" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + const browser = tab.linkedBrowser; + const onLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, SECOND_TEST_URL); + await onLoaded; + + info("Wait for the new target"); + await waitFor(() => targetCommand.targetFront != targetFront); + isnot( + targetCommand.targetFront, + targetFront, + "The top level target changes on navigation" + ); + ok( + !targetCommand.targetFront.isDestroyed(), + "The new target isn't destroyed" + ); + ok(targetFront.isDestroyed(), "While the previous target is destroyed"); + + targetCommand.destroy(); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} + +async function testWebExtension() { + info("Test TargetCommand against webextension descriptor"); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + name: "Sample extension", + }, + }); + + await extension.startup(); + + const commands = await CommandsFactory.forAddon(extension.id); + const { descriptorFront } = commands; + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.EXTENSION, + "The descriptor type is correct" + ); + is( + descriptorFront.isWebExtensionDescriptor, + true, + "Descriptor front isWebExtensionDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is(targets.length, 1, "Got a unique target"); + const targetFront = targets[0]; + is(targetFront, targetCommand.targetFront, "The first is the top level one"); + is( + targetFront.targetType, + targetCommand.TYPES.FRAME, + "the web extension target is of frame type, because it inherits from WindowGlobalTargetActor" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + targetCommand.destroy(); + + await extension.unload(); + + await commands.destroy(); +} + +// CommandsFactory expect the worker id, which is computed from the nsIWorkerDebugger.id attribute +function getNextWorkerDebuggerId() { + return new Promise(resolve => { + const wdm = Cc[ + "@mozilla.org/dom/workers/workerdebuggermanager;1" + ].createInstance(Ci.nsIWorkerDebuggerManager); + const listener = { + onRegister(dbg) { + wdm.removeListener(listener); + resolve(dbg.id); + }, + }; + wdm.addListener(listener); + }); +} +async function testWorker() { + info("Test TargetCommand against worker descriptor"); + + const workerUrl = CHROME_WORKER_URL + "#descriptor"; + const onNextWorker = getNextWorkerDebuggerId(); + const worker = new Worker(workerUrl); + const workerId = await onNextWorker; + ok(workerId, "Found the worker Debugger ID"); + + const commands = await CommandsFactory.forWorker(workerId); + const { descriptorFront } = commands; + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.WORKER, + "The descriptor type is correct" + ); + is( + descriptorFront.isWorkerDescriptor, + true, + "Descriptor front isWorkerDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is(targets.length, 1, "Got a unique target"); + const targetFront = targets[0]; + is(targetFront, targetCommand.targetFront, "The first is the top level one"); + is( + targetFront.targetType, + targetCommand.TYPES.WORKER, + "the worker target is of worker type" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + targetCommand.destroy(); + + // Calling CommandsFactory.forWorker, will call RootFront.getWorker + // which will spawn lots of worker legacy code, firing lots of requests, + // which may still be pending + await commands.waitForRequestsToSettle(); + + await commands.destroy(); + worker.terminate(); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_watchTargets.js b/devtools/shared/commands/target/tests/browser_target_command_watchTargets.js new file mode 100644 index 0000000000..516780be01 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_watchTargets.js @@ -0,0 +1,214 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand's `watchTargets` function + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`); + +add_task(async function () { + // Enabled fission's pref as the TargetCommand is almost disabled without it + await pushPref("devtools.browsertoolbox.scope", "everything"); + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + await testWatchTargets(); + await testThrowingInOnAvailable(); +}); + +async function testWatchTargets() { + info("Test TargetCommand watchTargets function"); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Note that ppmm also includes the parent process, which is considered as a frame rather than a process + const originalProcessesCount = Services.ppmm.childCount - 1; + + info( + "Check that onAvailable is called for processes already created *before* the call to watchTargets" + ); + const targets = new Set(); + const topLevelTarget = targetCommand.targetFront; + const onAvailable = ({ targetFront }) => { + if (targets.has(targetFront)) { + ok(false, "The same target is notified multiple times via onAvailable"); + } + is( + targetFront.targetType, + TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + targetFront == topLevelTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.add(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + if (!targets.has(targetFront)) { + ok( + false, + "A target is declared destroyed via onDestroyed without being notified via onAvailable" + ); + } + is( + targetFront.targetType, + TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + !targetFront.isTopLevel, + "We are not notified about the top level target destruction" + ); + targets.delete(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable, + onDestroyed, + }); + is( + targets.size, + originalProcessesCount, + "retrieved the expected number of processes via watchTargets" + ); + // Start from 1 in order to ignore the parent process target, which is considered as a frame rather than a process + for (let i = 1; i < Services.ppmm.childCount; i++) { + const process = Services.ppmm.getChildAt(i); + const hasTargetWithSamePID = [...targets].find( + processTarget => processTarget.targetForm.processID == process.osPid + ); + ok( + hasTargetWithSamePID, + `Process with PID ${process.osPid} has been reported via onAvailable` + ); + } + + info( + "Check that onAvailable is called for processes created *after* the call to watchTargets" + ); + const previousTargets = new Set(targets); + const onProcessCreated = new Promise(resolve => { + const onAvailable2 = ({ targetFront }) => { + if (previousTargets.has(targetFront)) { + return; + } + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable2, + }); + resolve(targetFront); + }; + targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable2, + }); + }); + const tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + const createdTarget = await onProcessCreated; + + // For some reason, creating a new tab purges processes created from previous tests + // so it is not reasonable to assert the side of `targets` as it may be lower than expected. + ok(targets.has(createdTarget), "The new tab process is in the list"); + + const processCountAfterTabOpen = targets.size; + + // Assert that onDestroyed is called for destroyed processes + const onProcessDestroyed = new Promise(resolve => { + const onAvailable3 = () => {}; + const onDestroyed3 = ({ targetFront }) => { + resolve(targetFront); + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable3, + onDestroyed: onDestroyed3, + }); + }; + targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable3, + onDestroyed: onDestroyed3, + }); + }); + + BrowserTestUtils.removeTab(tab1); + + const destroyedTarget = await onProcessDestroyed; + is( + targets.size, + processCountAfterTabOpen - 1, + "The closed tab's process has been reported as destroyed" + ); + ok( + !targets.has(destroyedTarget), + "The destroyed target is no longer in the list" + ); + is( + destroyedTarget, + createdTarget, + "The destroyed target is the one that has been reported as created" + ); + + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable, + onDestroyed, + }); + + targetCommand.destroy(); + + await commands.destroy(); +} + +async function testThrowingInOnAvailable() { + info( + "Test TargetCommand watchTargets function when an exception is thrown in onAvailable callback" + ); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Note that ppmm also includes the parent process, which is considered as a frame rather than a process + const originalProcessesCount = Services.ppmm.childCount - 1; + + info( + "Check that onAvailable is called for processes already created *before* the call to watchTargets" + ); + const targets = new Set(); + let thrown = false; + const onAvailable = ({ targetFront }) => { + if (!thrown) { + thrown = true; + throw new Error("Force an exception when processing the first target"); + } + targets.add(targetFront); + }; + await targetCommand.watchTargets({ types: [TYPES.PROCESS], onAvailable }); + is( + targets.size, + originalProcessesCount - 1, + "retrieved the expected number of processes via onAvailable. All but the first one where we have thrown." + ); + + targetCommand.destroy(); + + await commands.destroy(); +} diff --git a/devtools/shared/commands/target/tests/browser_watcher_actor_getter_caching.js b/devtools/shared/commands/target/tests/browser_watcher_actor_getter_caching.js new file mode 100644 index 0000000000..6dd99d243b --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_watcher_actor_getter_caching.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that watcher front/actor APIs do not lead to create duplicate actors. + +const TEST_URL = "data:text/html;charset=utf-8,Actor caching test"; + +add_task(async function () { + info("Setup the test page with workers of all types"); + const tab = await addTab(TEST_URL); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const { watcherFront } = targetCommand; + ok(watcherFront, "A watcherFront is available on targetCommand"); + + info("Check that getNetworkParentActor does not create duplicate actors"); + testActorGetter( + watcherFront, + () => watcherFront.getNetworkParentActor(), + "networkParent" + ); + + info("Check that getBreakpointListActor does not create duplicate actors"); + testActorGetter( + watcherFront, + () => watcherFront.getBreakpointListActor(), + "breakpoint-list" + ); + + info( + "Check that getTargetConfigurationActor does not create duplicate actors" + ); + testActorGetter( + watcherFront, + () => watcherFront.getTargetConfigurationActor(), + "target-configuration" + ); + + info( + "Check that getThreadConfigurationActor does not create duplicate actors" + ); + testActorGetter( + watcherFront, + () => watcherFront.getThreadConfigurationActor(), + "thread-configuration" + ); + + targetCommand.destroy(); + await commands.waitForRequestsToSettle(); + await commands.destroy(); +}); + +/** + * Check that calling an actor getter method on the watcher front leads to the + * creation of at most 1 actor. + */ +async function testActorGetter(watcherFront, actorGetterFn, typeName) { + checkPoolChildrenSize(watcherFront, typeName, 0); + + const actor = await actorGetterFn(); + checkPoolChildrenSize(watcherFront, typeName, 1); + + const otherActor = await actorGetterFn(); + is(actor, otherActor, "Returned the same actor for " + typeName); + + checkPoolChildrenSize(watcherFront, typeName, 1); +} + +/** + * Assert that a given parent pool has the expected number of children for + * a given typeName. + */ +function checkPoolChildrenSize(parentPool, typeName, expected) { + const children = [...parentPool.poolChildren()]; + const childrenByType = children.filter(pool => pool.typeName === typeName); + is( + childrenByType.length, + expected, + `${parentPool.actorID} should have ${expected} children of type ${typeName}` + ); +} diff --git a/devtools/shared/commands/target/tests/fission_document.html b/devtools/shared/commands/target/tests/fission_document.html new file mode 100644 index 0000000000..62afe347e3 --- /dev/null +++ b/devtools/shared/commands/target/tests/fission_document.html @@ -0,0 +1,47 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <script> + "use strict"; + + const params = new URLSearchParams(document.location.search); + + // eslint-disable-next-line no-unused-vars + const worker = new Worker("https://example.com/browser/devtools/shared/commands/target/tests/test_worker.js#simple-worker"); + + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker("https://example.com/browser/devtools/shared/commands/target/tests/test_worker.js#shared-worker"); + + if (!params.has("noServiceWorker")) { + // Expose a reference to the registration so that tests can unregister it. + window.registrationPromise = navigator.serviceWorker.register("https://example.com/browser/devtools/shared/commands/target/tests/test_service_worker.js#service-worker"); + } + + /* exported logMessageInWorker */ + function logMessageInWorker(message) { + worker.postMessage({ + type: "log-in-worker", + message, + }); + } + </script> +</head> +<body> +<p>Test fission iframe</p> + +<script> + "use strict"; + const iframe = document.createElement("iframe"); + let iframeUrl = `https://example.org/browser/devtools/shared/commands/target/tests/fission_iframe.html`; + if (document.location.search) { + iframeUrl += `?${new URLSearchParams(document.location.search)}`; + } + iframe.src = iframeUrl; + document.body.append(iframe); +</script> +</body> +</html> diff --git a/devtools/shared/commands/target/tests/fission_iframe.html b/devtools/shared/commands/target/tests/fission_iframe.html new file mode 100644 index 0000000000..deae49f833 --- /dev/null +++ b/devtools/shared/commands/target/tests/fission_iframe.html @@ -0,0 +1,29 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission iframe document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <script> + "use strict"; + const params = new URLSearchParams(document.location.search); + const hashSuffix = params.get("hashSuffix") || "in-iframe"; + // eslint-disable-next-line no-unused-vars + const worker = new Worker("test_worker.js#simple-worker-" + hashSuffix); + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker("test_worker.js#shared-worker-" + hashSuffix); + + /* exported logMessageInWorker */ + function logMessageInWorker(message) { + worker.postMessage({ + type: "log-in-worker", + message, + }); + } + </script> +</head> +<body> +<p>remote iframe</p> +</body> +</html> diff --git a/devtools/shared/commands/target/tests/head.js b/devtools/shared/commands/target/tests/head.js new file mode 100644 index 0000000000..ecb3fc1828 --- /dev/null +++ b/devtools/shared/commands/target/tests/head.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +const { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +async function createLocalClient() { + // Instantiate a minimal server + DevToolsServer.init(); + DevToolsServer.allowChromeProcess = true; + if (!DevToolsServer.createRootActor) { + DevToolsServer.registerAllActors(); + } + const transport = DevToolsServer.connectPipe(); + const client = new DevToolsClient(transport); + await client.connect(); + return client; +} diff --git a/devtools/shared/commands/target/tests/incremental-js-value-script.sjs b/devtools/shared/commands/target/tests/incremental-js-value-script.sjs new file mode 100644 index 0000000000..a612a3cb59 --- /dev/null +++ b/devtools/shared/commands/target/tests/incremental-js-value-script.sjs @@ -0,0 +1,23 @@ +"use strict"; + +function handleRequest(request, response) { + const Etag = '"4d881ab-b03-435f0a0f9ef00"'; + const IfNoneMatch = request.hasHeader("If-None-Match") + ? request.getHeader("If-None-Match") + : ""; + + const counter = getState("cache-counter") || 1; + const page = "<script>var jsValue = '" + counter + "';</script>" + counter; + + setState("cache-counter", "" + (parseInt(counter, 10) + 1)); + + response.setHeader("Etag", Etag, false); + + if (IfNoneMatch === Etag) { + response.setStatusLine(request.httpVersion, "304", "Not Modified"); + } else { + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader("Content-Length", page.length + "", false); + response.write(page); + } +} diff --git a/devtools/shared/commands/target/tests/simple_document.html b/devtools/shared/commands/target/tests/simple_document.html new file mode 100644 index 0000000000..d6a449e489 --- /dev/null +++ b/devtools/shared/commands/target/tests/simple_document.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test empty document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +</head> +<body> +<p>Test empty document</p> +</body> +</html> diff --git a/devtools/shared/commands/target/tests/test_service_worker.js b/devtools/shared/commands/target/tests/test_service_worker.js new file mode 100644 index 0000000000..aabc3fda0f --- /dev/null +++ b/devtools/shared/commands/target/tests/test_service_worker.js @@ -0,0 +1,11 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// We don't need any computation in the worker, +// but at least register a fetch listener so that +// we force instantiating the SW when loading the page. +self.onfetch = function (event) { + // do nothing. +}; diff --git a/devtools/shared/commands/target/tests/test_sw_page.html b/devtools/shared/commands/target/tests/test_sw_page.html new file mode 100644 index 0000000000..38aad04259 --- /dev/null +++ b/devtools/shared/commands/target/tests/test_sw_page.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test sw page</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +</head> +<body> +<p>Test sw page</p> + +<script> +"use strict"; + +// Expose a reference to the registration so that tests can unregister it. +window.registrationPromise = navigator.serviceWorker.register("test_sw_page_worker.js"); +</script> +</body> +</html> diff --git a/devtools/shared/commands/target/tests/test_sw_page_worker.js b/devtools/shared/commands/target/tests/test_sw_page_worker.js new file mode 100644 index 0000000000..29cda68560 --- /dev/null +++ b/devtools/shared/commands/target/tests/test_sw_page_worker.js @@ -0,0 +1,5 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// We don't need any computation in the worker, +// just it to be alive diff --git a/devtools/shared/commands/target/tests/test_worker.js b/devtools/shared/commands/target/tests/test_worker.js new file mode 100644 index 0000000000..ce3dd39cea --- /dev/null +++ b/devtools/shared/commands/target/tests/test_worker.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +globalThis.onmessage = function (e) { + const { type, message } = e.data; + + if (type === "log-in-worker") { + // Printing `e` so we can check that we have an object and not a stringified version + console.log("[WORKER]", message, e); + } +}; diff --git a/devtools/shared/commands/thread-configuration/moz.build b/devtools/shared/commands/thread-configuration/moz.build new file mode 100644 index 0000000000..28e8e0ffc4 --- /dev/null +++ b/devtools/shared/commands/thread-configuration/moz.build @@ -0,0 +1,7 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "thread-configuration-command.js", +) diff --git a/devtools/shared/commands/thread-configuration/tests/browser.toml b/devtools/shared/commands/thread-configuration/tests/browser.toml new file mode 100644 index 0000000000..bbd5485874 --- /dev/null +++ b/devtools/shared/commands/thread-configuration/tests/browser.toml @@ -0,0 +1,7 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "head.js", +] diff --git a/devtools/shared/commands/thread-configuration/tests/head.js b/devtools/shared/commands/thread-configuration/tests/head.js new file mode 100644 index 0000000000..ce65b3d827 --- /dev/null +++ b/devtools/shared/commands/thread-configuration/tests/head.js @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); diff --git a/devtools/shared/commands/thread-configuration/thread-configuration-command.js b/devtools/shared/commands/thread-configuration/thread-configuration-command.js new file mode 100644 index 0000000000..0db1c2a285 --- /dev/null +++ b/devtools/shared/commands/thread-configuration/thread-configuration-command.js @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * The ThreadConfigurationCommand should be used to maintain thread settings + * sent from the client for the thread actor. + * + * See the ThreadConfigurationActor for a list of supported configuration options. + */ +class ThreadConfigurationCommand { + constructor({ commands, watcherFront }) { + this._commands = commands; + this._watcherFront = watcherFront; + } + + /** + * Return a promise that resolves to the related thread configuration actor's front. + * + * @return {Promise<ThreadConfigurationFront>} + */ + async getThreadConfigurationFront() { + const front = await this._watcherFront.getThreadConfigurationActor(); + return front; + } + + async updateConfiguration(configuration) { + if (this._commands.targetCommand.hasTargetWatcherSupport()) { + // Remove thread options that are not currently supported by + // the thread configuration actor. + const filteredConfiguration = Object.fromEntries( + Object.entries(configuration).filter( + ([key, value]) => !["breakpoints", "eventBreakpoints"].includes(key) + ) + ); + + const threadConfigurationFront = await this.getThreadConfigurationFront(); + const updatedConfiguration = + await threadConfigurationFront.updateConfiguration( + filteredConfiguration + ); + this._configuration = updatedConfiguration; + } + + let threadFronts = await this._commands.targetCommand.getAllFronts( + this._commands.targetCommand.ALL_TYPES, + "thread" + ); + + // Lets always call reconfigure for all the target types that do not + // have target watcher support yet. e.g In the browser, even + // though `hasTargetWatcherSupport()` is true, only + // FRAME and CONTENT PROCESS targets use watcher actors, + // WORKER targets are supported via the legacy listerners. + threadFronts = threadFronts.filter( + threadFront => + !this._commands.targetCommand.hasTargetWatcherSupport( + threadFront.targetFront.targetType + ) + ); + + // Ignore threads that fail to be configured. + // Some workers may be destroying and `reconfigure` would be rejected. + await Promise.allSettled( + threadFronts.map(threadFront => threadFront.reconfigure(configuration)) + ); + } +} + +module.exports = ThreadConfigurationCommand; diff --git a/devtools/shared/commands/tracer/moz.build b/devtools/shared/commands/tracer/moz.build new file mode 100644 index 0000000000..63b3033655 --- /dev/null +++ b/devtools/shared/commands/tracer/moz.build @@ -0,0 +1,7 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "tracer-command.js", +) diff --git a/devtools/shared/commands/tracer/tracer-command.js b/devtools/shared/commands/tracer/tracer-command.js new file mode 100644 index 0000000000..f512c15d9e --- /dev/null +++ b/devtools/shared/commands/tracer/tracer-command.js @@ -0,0 +1,85 @@ +/* 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 TracerCommand { + constructor({ commands }) { + this.#targetCommand = commands.targetCommand; + this.#targetConfigurationCommand = commands.targetConfigurationCommand; + this.#resourceCommand = commands.resourceCommand; + } + + #resourceCommand; + #targetCommand; + #targetConfigurationCommand; + #isTracing = false; + + async initialize() { + return this.#resourceCommand.watchResources( + [this.#resourceCommand.TYPES.JSTRACER_STATE], + { onAvailable: this.onResourcesAvailable } + ); + } + destroy() { + this.#resourceCommand.unwatchResources( + [this.#resourceCommand.TYPES.JSTRACER_STATE], + { onAvailable: this.onResourcesAvailable } + ); + } + + onResourcesAvailable = resources => { + for (const resource of resources) { + if (resource.resourceType != this.#resourceCommand.TYPES.JSTRACER_STATE) { + continue; + } + this.#isTracing = resource.enabled; + } + }; + + /** + * Get the dictionary passed to the server codebase as a SessionData. + * This contains all settings to fine tune the tracer actual behavior. + * + * @return {JSON} + * Configuration object. + */ + #getTracingOptions() { + return { + logMethod: Services.prefs.getStringPref( + "devtools.debugger.javascript-tracing-log-method", + "" + ), + traceValues: Services.prefs.getBoolPref( + "devtools.debugger.javascript-tracing-values", + false + ), + traceOnNextInteraction: Services.prefs.getBoolPref( + "devtools.debugger.javascript-tracing-on-next-interaction", + false + ), + traceOnNextLoad: Services.prefs.getBoolPref( + "devtools.debugger.javascript-tracing-on-next-load", + false + ), + traceFunctionReturn: Services.prefs.getBoolPref( + "devtools.debugger.javascript-tracing-function-return", + false + ), + }; + } + + /** + * Toggle JavaScript tracing for all targets. + */ + async toggle() { + this.#isTracing = !this.#isTracing; + + await this.#targetConfigurationCommand.updateConfiguration({ + tracerOptions: this.#isTracing ? this.#getTracingOptions() : undefined, + }); + } +} + +module.exports = TracerCommand; |