diff options
Diffstat (limited to 'devtools/client/framework/browser-toolbox')
28 files changed, 2935 insertions, 0 deletions
diff --git a/devtools/client/framework/browser-toolbox/Launcher.sys.mjs b/devtools/client/framework/browser-toolbox/Launcher.sys.mjs new file mode 100644 index 0000000000..a619fbdff2 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/Launcher.sys.mjs @@ -0,0 +1,467 @@ +/* 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"; + +// Keep this synchronized with the value of the same name in +// toolkit/xre/nsAppRunner.cpp. +const BROWSER_TOOLBOX_WINDOW_URL = + "chrome://devtools/content/framework/browser-toolbox/window.html"; +const CHROME_DEBUGGER_PROFILE_NAME = "chrome_debugger_profile"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { require } from "resource://devtools/shared/loader/Loader.sys.mjs"; +import { + useDistinctSystemPrincipalLoader, + releaseDistinctSystemPrincipalLoader, +} from "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs"; +import { Subprocess } from "resource://gre/modules/Subprocess.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + BackgroundTasksUtils: "resource://gre/modules/BackgroundTasksUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + XreDirProvider: [ + "@mozilla.org/xre/directory-provider;1", + "nsIXREDirProvider", + ], +}); + +const Telemetry = require("resource://devtools/client/shared/telemetry.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +const processes = new Set(); + +/** + * @typedef {Object} BrowserToolboxLauncherArgs + * @property {function} onRun - A function called when the process starts running. + * @property {boolean} overwritePreferences - Set to force overwriting the toolbox + * profile's preferences with the current set of preferences. + * @property {boolean} forceMultiprocess - Set to force the Browser Toolbox to be in + * multiprocess mode. + */ + +export class BrowserToolboxLauncher extends EventEmitter { + /** + * Initializes and starts a chrome toolbox process if the appropriated prefs are enabled + * + * @param {BrowserToolboxLauncherArgs} args + * @return {BrowserToolboxLauncher|null} The created instance, or null if the required prefs + * are not set. + */ + static init(args) { + if ( + !Services.prefs.getBoolPref("devtools.chrome.enabled") || + !Services.prefs.getBoolPref("devtools.debugger.remote-enabled") + ) { + console.error("Could not start Browser Toolbox, you need to enable it."); + return null; + } + return new BrowserToolboxLauncher(args); + } + + /** + * Figure out if there are any open Browser Toolboxes that'll need to be restored. + * @return {boolean} + */ + static getBrowserToolboxSessionState() { + return processes.size !== 0; + } + + #closed; + #devToolsServer; + #dbgProfilePath; + #dbgProcess; + #listener; + #loader; + #port; + #telemetry = new Telemetry(); + + /** + * Constructor for creating a process that will hold a chrome toolbox. + * + * @param {...BrowserToolboxLauncherArgs} args + */ + constructor({ forceMultiprocess, onRun, overwritePreferences } = {}) { + super(); + + if (onRun) { + this.once("run", onRun); + } + + this.close = this.close.bind(this); + Services.obs.addObserver(this.close, "quit-application"); + this.#initServer(); + this.#initProfile(overwritePreferences); + this.#create({ forceMultiprocess }); + + processes.add(this); + } + + /** + * Initializes the devtools server. + */ + #initServer() { + if (this.#devToolsServer) { + dumpn("The chrome toolbox server is already running."); + return; + } + + dumpn("Initializing the chrome toolbox server."); + + // Create a separate loader instance, so that we can be sure to receive a + // separate instance of the DebuggingServer from the rest of the devtools. + // This allows us to safely use the tools against even the actors and + // DebuggingServer itself, especially since we can mark this loader as + // invisible to the debugger (unlike the usual loader settings). + this.#loader = useDistinctSystemPrincipalLoader(this); + const { DevToolsServer } = this.#loader.require( + "resource://devtools/server/devtools-server.js" + ); + const { SocketListener } = this.#loader.require( + "resource://devtools/shared/security/socket.js" + ); + this.#devToolsServer = DevToolsServer; + dumpn("Created a separate loader instance for the DevToolsServer."); + + this.#devToolsServer.init(); + // We mainly need a root actor and target actors for opening a toolbox, even + // against chrome/content. But the "no auto hide" button uses the + // preference actor, so also register the browser actors. + this.#devToolsServer.registerAllActors(); + this.#devToolsServer.allowChromeProcess = true; + dumpn("initialized and added the browser actors for the DevToolsServer."); + + const bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + if (bts?.isBackgroundTaskMode) { + // A special root actor, just for background tasks invoked with + // `--backgroundtask TASK --jsdebugger`. + const { createRootActor } = this.#loader.require( + "resource://gre/modules/backgroundtasks/dbg-actors.js" + ); + this.#devToolsServer.setRootActor(createRootActor); + } + + const chromeDebuggingWebSocket = Services.prefs.getBoolPref( + "devtools.debugger.chrome-debugging-websocket" + ); + const socketOptions = { + fromBrowserToolbox: true, + portOrPath: -1, + webSocket: chromeDebuggingWebSocket, + }; + const listener = new SocketListener(this.#devToolsServer, socketOptions); + listener.open(); + this.#listener = listener; + this.#port = listener.port; + + if (!this.#port) { + throw new Error("No devtools server port"); + } + + dumpn("Finished initializing the chrome toolbox server."); + dump( + `DevTools Server for Browser Toolbox listening on port: ${this.#port}\n` + ); + } + + /** + * Initializes a profile for the remote debugger process. + */ + #initProfile(overwritePreferences) { + dumpn("Initializing the chrome toolbox user profile."); + + const bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + + let debuggingProfileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + if (bts?.isBackgroundTaskMode) { + // Background tasks run with a temporary ephemeral profile. We move the + // browser toolbox profile out of that ephemeral profile so that it has + // alonger life then the background task profile. This preserves + // breakpoints, etc, across repeated debugging invocations. This + // directory is close to the background task temporary profile name(s), + // but doesn't match the prefix that will get purged by the stale + // ephemeral profile cleanup mechanism. + // + // For example, the invocation + // `firefox --backgroundtask success --jsdebugger --wait-for-jsdebugger` + // might run with ephemeral profile + // `/tmp/MozillaBackgroundTask-<HASH>-success` + // and sibling directory browser toolbox profile + // `/tmp/MozillaBackgroundTask-<HASH>-chrome_debugger_profile-success` + // + // See `BackgroundTasks::Shutdown` for ephemeral profile cleanup details. + debuggingProfileDir = debuggingProfileDir.parent; + debuggingProfileDir.append( + `${Services.appinfo.vendor}BackgroundTask-` + + `${lazy.XreDirProvider.getInstallHash()}-${CHROME_DEBUGGER_PROFILE_NAME}-${bts.backgroundTaskName()}` + ); + } else { + debuggingProfileDir.append(CHROME_DEBUGGER_PROFILE_NAME); + } + try { + debuggingProfileDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + } catch (ex) { + if (ex.result === Cr.NS_ERROR_FILE_ALREADY_EXISTS) { + if (!overwritePreferences) { + this.#dbgProfilePath = debuggingProfileDir.path; + return; + } + // Fall through and copy the current set of prefs to the profile. + } else { + dumpn("Error trying to create a profile directory, failing."); + dumpn("Error: " + (ex.message || ex)); + return; + } + } + + this.#dbgProfilePath = debuggingProfileDir.path; + + // We would like to copy prefs into this new profile... + const prefsFile = debuggingProfileDir.clone(); + prefsFile.append("prefs.js"); + + if (bts?.isBackgroundTaskMode) { + // Background tasks run under a temporary profile. In order to set + // preferences for the launched browser toolbox, take the preferences from + // the default profile. This is the standard pattern for controlling + // background task settings. Without this, there'd be no way to increase + // logging in the browser toolbox process, etc. + const defaultProfile = lazy.BackgroundTasksUtils.getDefaultProfile(); + if (!defaultProfile) { + throw new Error( + "Cannot start Browser Toolbox from background task with no default profile" + ); + } + + const defaultPrefsFile = defaultProfile.rootDir.clone(); + defaultPrefsFile.append("prefs.js"); + defaultPrefsFile.copyTo(prefsFile.parent, prefsFile.leafName); + + dumpn( + `Copied browser toolbox prefs at '${prefsFile.path}'` + + ` from default profiles prefs at '${defaultPrefsFile.path}'` + ); + } else { + // ... but unfortunately, when we run tests, it seems the starting profile + // clears out the prefs file before re-writing it, and in practice the + // file is empty when we get here. So just copying doesn't work in that + // case. + // We could force a sync pref flush and then copy it... but if we're doing + // that, we might as well just flush directly to the new profile, which + // always works: + Services.prefs.savePrefFile(prefsFile); + } + + dumpn( + "Finished creating the chrome toolbox user profile at: " + + this.#dbgProfilePath + ); + } + + /** + * Creates and initializes the profile & process for the remote debugger. + * + * @param {Object} options + * @param {boolean} options.forceMultiprocess: Set to true to force the Browser Toolbox to be in + * multiprocess mode. + */ + #create({ forceMultiprocess } = {}) { + dumpn("Initializing chrome debugging process."); + + let command = Services.dirsvc.get("XREExeF", Ci.nsIFile).path; + let profilePath = this.#dbgProfilePath; + + // MOZ_BROWSER_TOOLBOX_BINARY is an absolute file path to a custom firefox binary. + // This is especially useful when debugging debug builds which are really slow + // so that you could pass an optimized build for the browser toolbox. + // This is also useful when debugging a patch that break devtools, + // so that you could use a build that works for the browser toolbox. + const customBinaryPath = Services.env.get("MOZ_BROWSER_TOOLBOX_BINARY"); + if (customBinaryPath) { + command = customBinaryPath; + profilePath = PathUtils.join(PathUtils.tempDir, "browserToolboxProfile"); + } + + dumpn("Running chrome debugging process."); + const args = [ + "-no-remote", + "-foreground", + "-profile", + profilePath, + "-chrome", + BROWSER_TOOLBOX_WINDOW_URL, + ]; + + const isInputContextEnabled = Services.prefs.getBoolPref( + "devtools.webconsole.input.context", + false + ); + const environment = { + // Allow recording the startup of the browser toolbox when setting + // MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP=1 when running firefox. + MOZ_PROFILER_STARTUP: Services.env.get( + "MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP" + ), + // And prevent profiling any subsequent toolbox + MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP: "0", + + MOZ_BROWSER_TOOLBOX_FORCE_MULTIPROCESS: forceMultiprocess ? "1" : "0", + // Similar, but for the WebConsole input context dropdown. + MOZ_BROWSER_TOOLBOX_INPUT_CONTEXT: isInputContextEnabled ? "1" : "0", + // Disable safe mode for the new process in case this was opened via the + // keyboard shortcut. + MOZ_DISABLE_SAFE_MODE_KEY: "1", + MOZ_BROWSER_TOOLBOX_PORT: String(this.#port), + MOZ_HEADLESS: null, + // Never enable Marionette for the new process. + MOZ_MARIONETTE: null, + // Don't inherit debug settings from the process launching us. This can + // cause errors when log files collide. + MOZ_LOG: null, + MOZ_LOG_FILE: null, + XPCOM_MEM_BLOAT_LOG: null, + XPCOM_MEM_LEAK_LOG: null, + XPCOM_MEM_LOG_CLASSES: null, + XPCOM_MEM_REFCNT_LOG: null, + XRE_PROFILE_PATH: null, + XRE_PROFILE_LOCAL_PATH: null, + }; + + // During local development, incremental builds can trigger the main process + // to clear its startup cache with the "flag file" .purgecaches, but this + // file is removed during app startup time, so we aren't able to know if it + // was present in order to also clear the child profile's startup cache as + // well. + // + // As an approximation of "isLocalBuild", check for an unofficial build. + if (!AppConstants.MOZILLA_OFFICIAL) { + args.push("-purgecaches"); + } + + dump(`Starting Browser Toolbox ${command} ${args.join(" ")}\n`); + IOUtils.makeDirectory(profilePath, { ignoreExisting: true }) + .then(() => + Subprocess.call({ + command, + arguments: args, + environmentAppend: true, + stderr: "stdout", + environment, + }) + ) + .then(proc => { + this.#dbgProcess = proc; + + this.#telemetry.toolOpened("jsbrowserdebugger", this); + + dumpn("Chrome toolbox is now running..."); + this.emit("run", this, proc, this.#dbgProfilePath); + + proc.stdin.close(); + const dumpPipe = async pipe => { + let leftover = ""; + let data = await pipe.readString(); + while (data) { + data = leftover + data; + const lines = data.split(/\r\n|\r|\n/); + if (lines.length) { + for (const line of lines.slice(0, -1)) { + dump(`${proc.pid}> ${line}\n`); + } + leftover = lines[lines.length - 1]; + } + data = await pipe.readString(); + } + if (leftover) { + dump(`${proc.pid}> ${leftover}\n`); + } + }; + dumpPipe(proc.stdout); + + proc.wait().then(() => this.close()); + + return proc; + }) + .catch(err => { + console.log( + `Error loading Browser Toolbox: ${command} ${args.join(" ")}`, + err + ); + }); + } + + /** + * Closes the remote debugging server and kills the toolbox process. + */ + async close() { + if (this.#closed) { + return; + } + + this.#closed = true; + + dumpn("Cleaning up the chrome debugging process."); + + Services.obs.removeObserver(this.close, "quit-application"); + + // We tear down before killing the browser toolbox process to avoid leaking + // socket connection objects. + if (this.#listener) { + this.#listener.close(); + } + + // Note that the DevToolsServer can be shared with the DevToolsServer + // spawned by DevToolsFrameChild. We shouldn't destroy it from here. + // Instead we should let it auto-destroy itself once the last connection is closed. + this.#devToolsServer = null; + + this.#dbgProcess.stdout.close(); + await this.#dbgProcess.kill(); + + this.#telemetry.toolClosed("jsbrowserdebugger", this); + + dumpn("Chrome toolbox is now closed..."); + processes.delete(this); + + this.#dbgProcess = null; + if (this.#loader) { + releaseDistinctSystemPrincipalLoader(this); + } + this.#loader = null; + this.#telemetry = null; + } +} + +/** + * Helper method for debugging. + * @param string + */ +function dumpn(str) { + if (wantLogging) { + dump("DBG-FRONTEND: " + str + "\n"); + } +} + +var wantLogging = Services.prefs.getBoolPref("devtools.debugger.log"); +const prefObserver = { + observe: (...args) => { + wantLogging = Services.prefs.getBoolPref(args.pop()); + }, +}; +Services.prefs.addObserver("devtools.debugger.log", prefObserver); +const unloadObserver = function (subject) { + if (subject.wrappedJSObject == require("@loader/unload")) { + Services.prefs.removeObserver("devtools.debugger.log", prefObserver); + Services.obs.removeObserver(unloadObserver, "devtools:loader:destroy"); + } +}; +Services.obs.addObserver(unloadObserver, "devtools:loader:destroy"); diff --git a/devtools/client/framework/browser-toolbox/README.md b/devtools/client/framework/browser-toolbox/README.md new file mode 100644 index 0000000000..5f7b97aad7 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/README.md @@ -0,0 +1,37 @@ +# Browser Toolbox + +## Introduction + +The Browser Toolbox spawns a toolbox in a new dedicated Firefox instance to debug the currently running Firefox. This new instance runs in a distinct process. + +To enable it, you must first flip two preferences in the DevTools Options panel (F1): +- Enable browser chrome and add-on debugging toolboxes +- Enable remote debugging + +You can either start it via a keyboard shortcut (CmdOrCtrl+Alt+Shift+I) or via the Tools > Browser Tools > Browser Toolbox menu item. + +When describing the setup used by the Browser Toolbox, we will refer to those two distinct Firefox instances as: +- the target Firefox: this is the current instance, that we want to debug +- the client Firefox: this is the new instance that will only run the Browser Toolbox window + +## Browser Toolbox Architecture + +The startup sequence of the browser toolbox begins in the target Firefox. + +`browser-toolbox/Launcher.sys.mjs` will be first reponsible for creating a remote DevToolsServer. This new DevToolsServer runs in the parent process but is separated from any existing DevTools DevToolsServer that spawned earlier for regular DevTools usage. Thanks to this, we will be able to debug files loaded in those regular DevToolsServers used for content toolboxes, about:debugging, ... + +Then we need to start the client Firefox. To do that, `browser-toolbox/Launcher.sys.mjs` creates a profile that will be a copy of the current profile loaded in the target Firefox, so that all user preferences can be automatically ported over. As a reminder both client and target Firefox will run simultaneously, so they can't use the same profile. + +This new profile is stored inside the folder of the target profile, in a `chrome_debugger_profile` folder. So the next time the Browser Toolbox opens this for profile, it will be reused. + +Once the profile is ready (or if it was already there), `browser-toolbox/Launcher.sys.mjs` spawns a new Firefox instance with a few additional parameters, most importantly `-chrome chrome://devtools/content/framework/browser-toolbox/window.html`. + +This way Firefox will load `browser-toolbox/window.html` instead of the regular browser window. Most of the logic is then handled by `browser-toolbox/window.js` which will connect to the remote server opened on the target Firefox and will then load a toolbox connected to this server. + +## Debugging the Browser Toolbox + +Note that you can open a Browser Toolbox from the Browser Toolbox. Simply reuse the same shortcut as the one you used to open the first Browser Toolbox, but this time while the Browser Toolbox window is focused. + +Another Browser Toolbox will spawn, this time debugging the first Browser Toolbox Firefox instance. If you are curious about how this is done, `browser-toolbox/window.js` simply loads `browser-toolbox/Launcher.sys.mjs` and requests to open a new Browser Toolbox. + +This will open yet another Firefox instance, running in another process. And a new `chrome_debugger_profile` folder will be created inside the existing Browser Toolbox profile (which as explained in the previous section, is already in a `chrome_debugger_profile` folder under the target Firefox profile). diff --git a/devtools/client/framework/browser-toolbox/moz.build b/devtools/client/framework/browser-toolbox/moz.build new file mode 100644 index 0000000000..f04fedb0a4 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +BROWSER_CHROME_MANIFESTS += [ + "test/browser.toml", +] + +DevToolsModules( + "Launcher.sys.mjs", +) diff --git a/devtools/client/framework/browser-toolbox/test/browser.toml b/devtools/client/framework/browser-toolbox/test/browser.toml new file mode 100644 index 0000000000..1fc6dcaa39 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser.toml @@ -0,0 +1,53 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +skip-if = ["asan"] # UNTIL Bug 1591064 IS FIXED ALL NEW TESTS SHOULD BE SKIPPED ON ASAN +support-files = [ + "doc_browser_toolbox_fission_contentframe_inspector_frame.html", + "doc_browser_toolbox_fission_contentframe_inspector_page.html", + "doc_browser_toolbox_ruleview_stylesheet.html", + "style_browser_toolbox_ruleview_stylesheet.css", + "head.js", + "helpers-browser-toolbox.js", + "!/devtools/client/debugger/test/mochitest/shared-head.js", + "!/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", +] +prefs = ["security.allow_unsafe_parent_loads=true"] # This is far from ideal. Bug 1565279 covers removing this pref flip. + +["browser_browser_toolbox.js"] + +["browser_browser_toolbox_debugger.js"] +skip-if = ["os == 'linux' && bits == 64 && debug"] # Bug 1756616 + +["browser_browser_toolbox_evaluation_context.js"] + +["browser_browser_toolbox_fission_contentframe_inspector.js"] +skip-if = ["os == 'linux' && bits == 64 && debug"] # Bug 1604751 + +["browser_browser_toolbox_fission_inspector.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_browser_toolbox_fission_inspector_webextension.js"] + +["browser_browser_toolbox_l10n_buttons.js"] + +["browser_browser_toolbox_navigate_tab.js"] + +["browser_browser_toolbox_netmonitor.js"] +skip-if = ["os == 'linux' && bits == 64 && !debug"] # Bug 1777831 + +["browser_browser_toolbox_print_preview.js"] + +["browser_browser_toolbox_rtl.js"] + +["browser_browser_toolbox_ruleview_stylesheet.js"] +skip-if = ["os == 'mac' && fission"] # high frequency intermittent + +["browser_browser_toolbox_shouldprocessupdates.js"] + +["browser_browser_toolbox_unavailable_children.js"] + +["browser_browser_toolbox_watchedByDevTools.js"] diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox.js new file mode 100644 index 0000000000..29d8856b05 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +add_task(async function () { + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({}); + + const hasCloseButton = await ToolboxTask.spawn(null, async () => { + /* global gToolbox */ + return !!gToolbox.doc.getElementById("toolbox-close"); + }); + ok(!hasCloseButton, "Browser toolbox doesn't have a close button"); + + info("Trigger F5 key shortcut and ensure nothing happens"); + info( + "If F5 triggers a full reload, the mochitest will stop here as firefox instance will be restarted" + ); + const previousInnerWindowId = + window.browsingContext.currentWindowGlobal.innerWindowId; + function onUnload() { + ok(false, "The top level window shouldn't be reloaded/closed"); + } + window.addEventListener("unload", onUnload); + await ToolboxTask.spawn(null, async () => { + const isMacOS = Services.appinfo.OS === "Darwin"; + const { win } = gToolbox; + // Simulate CmdOrCtrl+R + win.dispatchEvent( + new win.KeyboardEvent("keydown", { + bubbles: true, + ctrlKey: !isMacOS, + metaKey: isMacOS, + keyCode: "r".charCodeAt(0), + }) + ); + // Simulate F5 + win.dispatchEvent( + new win.KeyboardEvent("keydown", { + bubbles: true, + keyCode: win.KeyEvent.DOM_VK_F5, + }) + ); + }); + + // Let a chance to trigger the regression where the top level document closes or reloads + await wait(1000); + + is( + window.browsingContext.currentWindowGlobal.innerWindowId, + previousInnerWindowId, + "Check the browser.xhtml wasn't reloaded when pressing F5" + ); + window.removeEventListener("unload", onUnload); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_debugger.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_debugger.js new file mode 100644 index 0000000000..edcba359e2 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_debugger.js @@ -0,0 +1,222 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test asserts that the new debugger works from the browser toolbox process + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +// On debug test runner, it takes about 50s to run the test. +requestLongerTimeout(4); + +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +const { fetch } = require("resource://devtools/shared/DevToolsUtils.js"); + +const debuggerHeadURL = + CHROME_URL_ROOT + "../../../debugger/test/mochitest/shared-head.js"; + +add_task(async function runTest() { + let { content: debuggerHead } = await fetch(debuggerHeadURL); + + // We remove its import of shared-head, which isn't available in browser toolbox process + // And isn't needed thanks to testHead's symbols + debuggerHead = debuggerHead.replace( + /Services.scriptloader.loadSubScript[^\)]*\);/g, + "" + ); + + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ + // head.js uses this method + registerCleanupFunction: () => {}, + waitForDispatch, + waitUntil, + }); + await ToolboxTask.importScript(debuggerHead); + + info("### First test breakpoint in the parent process script"); + const s = Cu.Sandbox("http://mozilla.org"); + + // Use a unique id for the fake script name in order to be able to run + // this test more than once. That's because the Sandbox is not immediately + // destroyed and so the debugger would display only one file but not necessarily + // connected to the latest sandbox. + const id = new Date().getTime(); + + // Pass a fake URL to evalInSandbox. If we just pass a filename, + // Debugger is going to fail and only display root folder (`/`) listing. + // But it won't try to fetch this url and use sandbox content as expected. + const testUrl = `http://mozilla.org/browser-toolbox-test-${id}.js`; + Cu.evalInSandbox( + `this.plop = function plop() { + const foo = 1; + return foo; +};`, + s, + "1.8", + testUrl, + 0 + ); + + // Execute the function every second in order to trigger the breakpoint + const interval = setInterval(s.plop, 1000); + + await ToolboxTask.spawn(testUrl, async _testUrl => { + /* global gToolbox, createDebuggerContext, waitForSources, waitForPaused, + addBreakpoint, assertPausedAtSourceAndLine, stepIn, findSource, + removeBreakpoint, resume, selectSource, assertNotPaused, assertBreakpoint, + assertTextContentOnLine, waitForResumed */ + Services.prefs.clearUserPref("devtools.debugger.tabs"); + Services.prefs.clearUserPref("devtools.debugger.pending-selected-location"); + + info("Waiting for debugger load"); + await gToolbox.selectTool("jsdebugger"); + const dbg = createDebuggerContext(gToolbox); + + await waitForSources(dbg, _testUrl); + + info("Loaded, selecting the test script to debug"); + const fileName = _testUrl.match(/browser-toolbox-test.*\.js/)[0]; + await selectSource(dbg, fileName); + + info("Add a breakpoint and wait to be paused"); + const onPaused = waitForPaused(dbg); + await addBreakpoint(dbg, fileName, 2); + await onPaused; + + const source = findSource(dbg, fileName); + assertPausedAtSourceAndLine(dbg, source.id, 2); + assertTextContentOnLine(dbg, 2, "const foo = 1;"); + is( + dbg.selectors.getBreakpointCount(), + 1, + "There is exactly one breakpoint" + ); + + await stepIn(dbg); + + assertPausedAtSourceAndLine(dbg, source.id, 3); + assertTextContentOnLine(dbg, 3, "return foo;"); + is( + dbg.selectors.getBreakpointCount(), + 1, + "We still have only one breakpoint after step-in" + ); + + // Remove the breakpoint before resuming in order to prevent hitting the breakpoint + // again during test closing. + await removeBreakpoint(dbg, source.id, 2); + + await resume(dbg); + + // Let a change for the interval to re-execute + await new Promise(r => setTimeout(r, 1000)); + + is(dbg.selectors.getBreakpointCount(), 0, "There is no more breakpoints"); + + assertNotPaused(dbg); + }); + + clearInterval(interval); + + info("### Now test breakpoint in a privileged content process script"); + const testUrl2 = `http://mozilla.org/content-process-test-${id}.js`; + await SpecialPowers.spawn(gBrowser.selectedBrowser, [testUrl2], testUrl => { + // Use a sandbox in order to have a URL to set a breakpoint + const s = Cu.Sandbox("http://mozilla.org"); + Cu.evalInSandbox( + `this.foo = function foo() { + const plop = 1; + return plop; +};`, + s, + "1.8", + testUrl, + 0 + ); + content.interval = content.setInterval(s.foo, 1000); + }); + await ToolboxTask.spawn(testUrl2, async _testUrl => { + const dbg = createDebuggerContext(gToolbox); + + const fileName = _testUrl.match(/content-process-test.*\.js/)[0]; + await waitForSources(dbg, _testUrl); + + await selectSource(dbg, fileName); + + const onPaused = waitForPaused(dbg); + await addBreakpoint(dbg, fileName, 2); + await onPaused; + + const source = findSource(dbg, fileName); + assertPausedAtSourceAndLine(dbg, source.id, 2); + assertTextContentOnLine(dbg, 2, "const plop = 1;"); + await assertBreakpoint(dbg, 2); + is(dbg.selectors.getBreakpointCount(), 1, "We have exactly one breakpoint"); + + await stepIn(dbg); + + assertPausedAtSourceAndLine(dbg, source.id, 3); + assertTextContentOnLine(dbg, 3, "return plop;"); + is( + dbg.selectors.getBreakpointCount(), + 1, + "We still have only one breakpoint after step-in" + ); + + // Remove the breakpoint before resuming in order to prevent hitting the breakpoint + // again during test closing. + await removeBreakpoint(dbg, source.id, 2); + + await resume(dbg); + + // Let a change for the interval to re-execute + await new Promise(r => setTimeout(r, 1000)); + + is(dbg.selectors.getBreakpointCount(), 0, "There is no more breakpoints"); + + assertNotPaused(dbg); + }); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.clearInterval(content.interval); + }); + + info("Trying pausing in a content process that crashes"); + + const crashingUrl = + "data:text/html,<script>setTimeout(()=>{debugger;})</script>"; + const crashingTab = await addTab(crashingUrl); + await ToolboxTask.spawn(crashingUrl, async url => { + const dbg = createDebuggerContext(gToolbox); + await waitForPaused(dbg); + const source = findSource(dbg, url); + assertPausedAtSourceAndLine(dbg, source.id, 1); + const thread = dbg.selectors.getThread(dbg.selectors.getCurrentThread()); + is(thread.isTopLevel, false, "The current thread is not the top level one"); + is(thread.targetType, "process", "The current thread is the tab one"); + }); + + info( + "Crash the tab and ensure the debugger resumes and switch to the main thread" + ); + await BrowserTestUtils.crashFrame(crashingTab.linkedBrowser); + + await ToolboxTask.spawn(null, async () => { + const dbg = createDebuggerContext(gToolbox); + await waitForResumed(dbg); + const thread = dbg.selectors.getThread(dbg.selectors.getCurrentThread()); + is(thread.isTopLevel, true, "The current thread is the top level one"); + }); + + await removeTab(crashingTab); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_evaluation_context.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_evaluation_context.js new file mode 100644 index 0000000000..34e18d15c5 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_evaluation_context.js @@ -0,0 +1,199 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// This test is used to test fission-like features via the Browser Toolbox: +// - the evaluation context selector in the console show the right targets +// - the iframe dropdown also show the right targets +// - both are updated accordingly when toggle to parent-process only scope + +add_task(async function () { + // Forces the Browser Toolbox to open on the console by default + await pushPref("devtools.browsertoolbox.panel", "webconsole"); + await pushPref("devtools.webconsole.input.context", true); + // Force EFT to have targets for all WindowGlobals + await pushPref("devtools.every-frame-target.enabled", true); + // Enable Multiprocess Browser Toolbox + await pushPref("devtools.browsertoolbox.scope", "everything"); + + // Open the test *before* opening the Browser toolbox in order to have the right target title. + // Once created, the target won't update its title, and so would be "New Tab", instead of "Test tab" + const tab = await addTab( + "https://example.com/document-builder.sjs?html=<html><title>Test tab</title></html>" + ); + + const ToolboxTask = await initBrowserToolboxTask(); + + await ToolboxTask.importFunctions({ + waitUntil, + getContextLabels, + getFramesLabels, + }); + + const tabProcessID = + tab.linkedBrowser.browsingContext.currentWindowGlobal.osPid; + + const decodedTabURI = decodeURI(tab.linkedBrowser.currentURI.spec); + + await ToolboxTask.spawn( + [tabProcessID, isFissionEnabled(), decodedTabURI], + async (processID, _isFissionEnabled, tabURI) => { + /* global gToolbox */ + const { hud } = await gToolbox.getPanel("webconsole"); + + const evaluationContextSelectorButton = hud.ui.outputNode.querySelector( + ".webconsole-evaluation-selector-button" + ); + + is( + !!evaluationContextSelectorButton, + true, + "The evaluation context selector is visible" + ); + is( + evaluationContextSelectorButton.innerText, + "Top", + "The button has the expected 'Top' text" + ); + + const labelTexts = getContextLabels(gToolbox); + + const expectedTitle = _isFissionEnabled + ? `(pid ${processID}) https://example.com` + : `(pid ${processID}) web`; + ok( + labelTexts.includes(expectedTitle), + `${processID} content process visible in the execution context (${labelTexts})` + ); + + ok( + labelTexts.includes(`Test tab`), + `Test tab is visible in the execution context (${labelTexts})` + ); + + // Also assert the behavior of the iframe dropdown and the mode selector + info("Check the iframe dropdown, start by opening it"); + const btn = gToolbox.doc.getElementById("command-button-frames"); + btn.click(); + + const panel = gToolbox.doc.getElementById("command-button-frames-panel"); + ok(panel, "popup panel has created."); + await waitUntil( + () => panel.classList.contains("tooltip-visible"), + "Wait for the menu to be displayed" + ); + + is( + getFramesLabels(gToolbox)[0], + "chrome://browser/content/browser.xhtml", + "The iframe dropdown lists first browser.xhtml, running in the parent process" + ); + ok( + getFramesLabels(gToolbox).includes(tabURI), + "The iframe dropdown lists the tab document, running in the content process" + ); + + // Click on top frame to hide the iframe picker, so clicks on other elements can be registered. + gToolbox.doc.querySelector("#toolbox-frame-menu .command").click(); + + await waitUntil( + () => !panel.classList.contains("tooltip-visible"), + "Wait for the menu to be hidden" + ); + + info("Check that the ChromeDebugToolbar is displayed"); + const chromeDebugToolbar = gToolbox.doc.querySelector( + ".chrome-debug-toolbar" + ); + ok(!!chromeDebugToolbar, "ChromeDebugToolbar is displayed"); + const chromeDebugToolbarScopeInputs = Array.from( + chromeDebugToolbar.querySelectorAll(`[name="chrome-debug-mode"]`) + ); + is( + chromeDebugToolbarScopeInputs.length, + 2, + "There are 2 mode inputs in the chromeDebugToolbar" + ); + const [ + chromeDebugToolbarParentProcessModeInput, + chromeDebugToolbarMultiprocessModeInput, + ] = chromeDebugToolbarScopeInputs; + is( + chromeDebugToolbarParentProcessModeInput.value, + "parent-process", + "Got expected value for the first input" + ); + is( + chromeDebugToolbarMultiprocessModeInput.value, + "everything", + "Got expected value for the second input" + ); + ok( + chromeDebugToolbarMultiprocessModeInput.checked, + "The multiprocess mode is selected" + ); + + info( + "Click on the parent-process input and check that it restricts the targets" + ); + chromeDebugToolbarParentProcessModeInput.click(); + info("Wait for the iframe dropdown to hide the tab target"); + await waitUntil(() => { + return !getFramesLabels(gToolbox).includes(tabURI); + }); + + info("Wait for the context selector to hide the tab context"); + await waitUntil(() => { + return !getContextLabels(gToolbox).includes(`Test tab`); + }); + + ok( + !chromeDebugToolbarMultiprocessModeInput.checked, + "Now, the multiprocess mode is disabled…" + ); + ok( + chromeDebugToolbarParentProcessModeInput.checked, + "…and the parent process mode is enabled" + ); + + info("Switch back to multiprocess mode"); + chromeDebugToolbarMultiprocessModeInput.click(); + + info("Wait for the iframe dropdown to show again the tab target"); + await waitUntil(() => { + return getFramesLabels(gToolbox).includes(tabURI); + }); + + info("Wait for the context selector to show again the tab context"); + await waitUntil(() => { + return getContextLabels(gToolbox).includes(`Test tab`); + }); + } + ); + + await ToolboxTask.destroy(); +}); + +function getContextLabels(toolbox) { + // Note that the context menu is in the top level chrome document (toolbox.xhtml) + // instead of webconsole.xhtml. + const labels = toolbox.doc.querySelectorAll( + "#webconsole-console-evaluation-context-selector-menu-list li .label" + ); + return Array.from(labels).map(item => item.textContent); +} + +function getFramesLabels(toolbox) { + return Array.from( + toolbox.doc.querySelectorAll("#toolbox-frame-menu .command .label") + ).map(el => el.textContent); +} diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_contentframe_inspector.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_contentframe_inspector.js new file mode 100644 index 0000000000..ba2ab2779c --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_contentframe_inspector.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +/** + * Check that different-site iframes can be expanded in the Omniscient Browser + * Toolbox. The test is supposed to run successfully with or without fission. + * Pass --enable-fission to ./mach test to enable fission when running this + * test locally. + */ +add_task(async function () { + await pushPref("devtools.browsertoolbox.scope", "everything"); + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ + getNodeFront, + getNodeFrontInFrames, + selectNode, + // selectNodeInFrames depends on selectNode, getNodeFront, getNodeFrontInFrames. + selectNodeInFrames, + }); + + const tab = await addTab( + `https://example.com/browser/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_page.html` + ); + + // Set a custom attribute on the tab's browser, in order to easily select it in the markup view + tab.linkedBrowser.setAttribute("test-tab", "true"); + + const testAttribute = await ToolboxTask.spawn(null, async () => { + /* global gToolbox */ + const inspector = await gToolbox.selectTool("inspector"); + const onSidebarSelect = inspector.sidebar.once("select"); + inspector.sidebar.select("computedview"); + await onSidebarSelect; + + info("Select the test element nested in the remote iframe"); + const nodeFront = await selectNodeInFrames( + ['browser[remote="true"][test-tab]', "iframe", "#inside-iframe"], + inspector + ); + + return nodeFront.getAttribute("test-attribute"); + }); + + is( + testAttribute, + "fission", + "Could successfully read attribute on a node inside a remote iframe" + ); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_inspector.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_inspector.js new file mode 100644 index 0000000000..24c7fa7918 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_inspector.js @@ -0,0 +1,220 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// This test is used to test fission-like features via the Browser Toolbox: +// - computed view is correct when selecting an element in a remote frame + +add_task(async function () { + // Forces the Browser Toolbox to open on the inspector by default + await pushPref("devtools.browsertoolbox.panel", "inspector"); + // Enable Multiprocess Browser Toolbox + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ + getNodeFront, + getNodeFrontInFrames, + selectNode, + // selectNodeInFrames depends on selectNode, getNodeFront, getNodeFrontInFrames. + selectNodeInFrames, + }); + + // Open the tab *after* opening the Browser Toolbox in order to force creating the remote frames + // late and exercise frame target watching code. + const tab = await addTab( + `data:text/html,<div id="my-div" style="color: red">Foo</div><div id="second-div" style="color: blue">Foo</div>` + ); + // Set a custom attribute on the tab's browser, in order to easily select it in the markup view + tab.linkedBrowser.setAttribute("test-tab", "true"); + + const color = await ToolboxTask.spawn(null, async () => { + /* global gToolbox */ + const inspector = gToolbox.getPanel("inspector"); + const onSidebarSelect = inspector.sidebar.once("select"); + inspector.sidebar.select("computedview"); + await onSidebarSelect; + + await selectNodeInFrames( + ['browser[remote="true"][test-tab]', "#my-div"], + inspector + ); + + const view = inspector.getPanel("computedview").computedView; + function getProperty(name) { + const propertyViews = view.propertyViews; + for (const propView of propertyViews) { + if (propView.name == name) { + return propView; + } + } + return null; + } + const prop = getProperty("color"); + return prop.valueNode.textContent; + }); + + is( + color, + "rgb(255, 0, 0)", + "The color property of the <div> within a tab isn't red" + ); + + info("Check that the node picker can be used on element in the content page"); + await pickNodeInContentPage( + ToolboxTask, + tab, + "browser[test-tab]", + "#second-div" + ); + const secondColor = await ToolboxTask.spawn(null, async () => { + const inspector = gToolbox.getPanel("inspector"); + + is( + inspector.selection.nodeFront.id, + "second-div", + "The expected element is selected in the inspector" + ); + + const view = inspector.getPanel("computedview").computedView; + function getProperty(name) { + const propertyViews = view.propertyViews; + for (const propView of propertyViews) { + if (propView.name == name) { + return propView; + } + } + return null; + } + const prop = getProperty("color"); + return prop.valueNode.textContent; + }); + + is( + secondColor, + "rgb(0, 0, 255)", + "The color property of the <div> within a tab isn't blue" + ); + + info( + "Check that the node picker can be used for element in non-remote <browser>" + ); + const nonRemoteUrl = "about:robots"; + const nonRemoteTab = await addTab(nonRemoteUrl); + // Set a custom attribute on the tab's browser, in order to target it + nonRemoteTab.linkedBrowser.setAttribute("test-tab-non-remote", ""); + + // check that the browser element is indeed not remote. If that changes for about:robots, + // this should be replaced with another page + is( + nonRemoteTab.linkedBrowser.hasAttribute("remote"), + false, + "The <browser> element for about:robots is not remote" + ); + + await pickNodeInContentPage( + ToolboxTask, + nonRemoteTab, + "browser[test-tab-non-remote]", + "#errorTryAgain" + ); + + await ToolboxTask.spawn(null, async () => { + const inspector = gToolbox.getPanel("inspector"); + is( + inspector.selection.nodeFront.id, + "errorTryAgain", + "The element inside a non-remote <browser> element is selected in the inspector" + ); + }); + + await ToolboxTask.destroy(); +}); + +async function pickNodeInContentPage( + ToolboxTask, + tab, + browserElementSelector, + contentElementSelector +) { + await ToolboxTask.spawn(contentElementSelector, async _selector => { + const onPickerStarted = gToolbox.nodePicker.once("picker-started"); + + // Wait until the inspector front was initialized in the target that + // contains the element we want to pick. + // Otherwise, even if the picker is "started", the corresponding WalkerActor + // might not be listening to the correct pick events (WalkerActor::pick) + const onPickerReady = new Promise(resolve => { + gToolbox.nodePicker.on( + "inspector-front-ready-for-picker", + async function onFrontReady(walker) { + if (await walker.querySelector(walker.rootNode, _selector)) { + gToolbox.nodePicker.off( + "inspector-front-ready-for-picker", + onFrontReady + ); + resolve(); + } + } + ); + }); + + gToolbox.nodePicker.start(); + await onPickerStarted; + await onPickerReady; + + const inspector = gToolbox.getPanel("inspector"); + + // Save the promises for later tasks, in order to start listening + // *before* hovering the element and wait for resolution *after* hovering. + this.onPickerStopped = gToolbox.nodePicker.once("picker-stopped"); + this.onInspectorUpdated = inspector.once("inspector-updated"); + }); + + // Retrieve the position of the element we want to pick in the content page + const { x, y } = await SpecialPowers.spawn( + tab.linkedBrowser, + [contentElementSelector], + _selector => { + const rect = content.document + .querySelector(_selector) + .getBoundingClientRect(); + return { x: rect.x, y: rect.y }; + } + ); + + // Synthesize the mouse event in the top level browsing context, but on the <browser> + // element containing the tab we're looking at, at the position where should be the + // content element. + // We need to do this to mimick what's actually done in node-picker.js + await EventUtils.synthesizeMouse( + document.querySelector(browserElementSelector), + x + 5, + y + 5, + {} + ); + + await ToolboxTask.spawn(null, async () => { + info(" # Waiting for picker stop"); + await this.onPickerStopped; + info(" # Waiting for inspector-updated"); + await this.onInspectorUpdated; + + delete this.onPickerStopped; + delete this.onInspectorUpdated; + }); +} diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_inspector_webextension.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_inspector_webextension.js new file mode 100644 index 0000000000..4f8a2f7535 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_inspector_webextension.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// Test that expanding a browser element of a webextension in the browser toolbox works +// as expected (See Bug 1696862). + +add_task(async function () { + const extension = ExtensionTestUtils.loadExtension({ + // manifest_version: 2, + manifest: { + sidebar_action: { + default_title: "SideBarExtensionTest", + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + + files: { + "sidebar.html": ` + <!DOCTYPE html> + <html class="sidebar-extension-test"> + <head> + <meta charset="utf-8"> + <script src="sidebar.js"></script> + </head> + <body> + <h1 id="sidebar-extension-h1">Sidebar Extension Test</h1> + </body> + </html>`, + "sidebar.js": function () { + window.onload = () => { + // eslint-disable-next-line no-undef + browser.test.sendMessage("sidebar-ready"); + }; + }, + }, + }); + await extension.startup(); + await extension.awaitMessage("sidebar-ready"); + + ok(true, "Extension sidebar is displayed"); + + // Forces the Browser Toolbox to open on the inspector by default + await pushPref("devtools.browsertoolbox.panel", "inspector"); + // Enable Multiprocess Browser Toolbox + await pushPref("devtools.browsertoolbox.scope", "everything"); + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ + getNodeFront, + getNodeFrontInFrames, + selectNode, + // selectNodeInFrames depends on selectNode, getNodeFront, getNodeFrontInFrames. + selectNodeInFrames, + }); + + const nodeId = await ToolboxTask.spawn(null, async () => { + /* global gToolbox */ + const inspector = gToolbox.getPanel("inspector"); + + const nodeFront = await selectNodeInFrames( + [ + "browser#sidebar", + "browser#webext-panels-browser", + "html.sidebar-extension-test h1", + ], + inspector + ); + return nodeFront.id; + }); + + is( + nodeId, + "sidebar-extension-h1", + "The Browser Toolbox can inspect a node in the webextension sidebar document" + ); + + await ToolboxTask.destroy(); + await extension.unload(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_l10n_buttons.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_l10n_buttons.js new file mode 100644 index 0000000000..abd2f3806a --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_l10n_buttons.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +/** + * In the browser toolbox there are options to switch the language to the "bidi" and + * "accented" languages. These are useful for making sure the browser is correctly + * localized. This test opens the browser toolbox, and checks that these buttons + * work. + */ +add_task(async function () { + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ clickMeatballItem }); + + is(getPseudoLocale(), "", "Starts out as empty"); + + await ToolboxTask.spawn(null, () => clickMeatballItem("accented")); + is(getPseudoLocale(), "accented", "Enabled the accented pseudo-locale"); + + await ToolboxTask.spawn(null, () => clickMeatballItem("accented")); + is(getPseudoLocale(), "", "Disabled the accented pseudo-locale."); + + await ToolboxTask.spawn(null, () => clickMeatballItem("bidi")); + is(getPseudoLocale(), "bidi", "Enabled the bidi pseudo-locale."); + + await ToolboxTask.spawn(null, () => clickMeatballItem("bidi")); + is(getPseudoLocale(), "", "Disabled the bidi pseudo-locale."); + + await ToolboxTask.spawn(null, () => clickMeatballItem("bidi")); + is(getPseudoLocale(), "bidi", "Enabled the bidi before closing."); + + await ToolboxTask.destroy(); + + is(getPseudoLocale(), "", "After closing the pseudo-locale is disabled."); +}); + +/** + * Return the pseudo-locale preference of the debuggee browser (not the browser toolbox). + * + * Another option for this test would be to test the text and layout of the + * browser directly, but this could be brittle. Checking the preference will + * hopefully provide adequate coverage. + */ +function getPseudoLocale() { + return Services.prefs.getCharPref("intl.l10n.pseudo"); +} + +/** + * This function is a ToolboxTask and is cloned into the toolbox context. It opens the + * "meatball menu" in the browser toolbox, clicks one of the pseudo-locale + * options, and finally returns the pseudo-locale preference from the target browser. + * + * @param {"accented" | "bidi"} type + */ +function clickMeatballItem(type) { + return new Promise(resolve => { + /* global gToolbox */ + + dump(`Opening the meatball menu in the browser toolbox.\n`); + gToolbox.doc.getElementById("toolbox-meatball-menu-button").click(); + + gToolbox.doc.addEventListener( + "popupshown", + async () => { + const menuItem = gToolbox.doc.getElementById( + "toolbox-meatball-menu-pseudo-locale-" + type + ); + dump(`Clicking the meatball menu item: "${type}".\n`); + menuItem.click(); + + // Request the pseudo-locale so that we know the preference actor is fully + // done setting the debuggee browser. + await gToolbox.getPseudoLocale(); + resolve(); + }, + { once: true } + ); + }); +} diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_navigate_tab.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_navigate_tab.js new file mode 100644 index 0000000000..46a6564a39 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_navigate_tab.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// Test that the Browser Toolbox still works after navigating a content tab +add_task(async function () { + // Forces the Browser Toolbox to open on the inspector by default + await pushPref("devtools.browsertoolbox.panel", "inspector"); + + await testNavigate("everything"); + await testNavigate("parent-process"); +}); + +async function testNavigate(browserToolboxScope) { + await pushPref("devtools.browsertoolbox.scope", browserToolboxScope); + + const tab = await addTab( + `data:text/html,<div>NAVIGATE TEST - BEFORE: ${browserToolboxScope}</div>` + ); + // Set the scope on the browser element to assert it easily in the Toolbox + // task. + tab.linkedBrowser.setAttribute("data-test-scope", browserToolboxScope); + + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ + getNodeFront, + selectNode, + }); + + const hasBrowserContainerTask = async ({ scope, hasNavigated }) => { + /* global gToolbox */ + const inspector = await gToolbox.selectTool("inspector"); + info("Select the test browser element in the inspector"); + let selector = `browser[data-test-scope="${scope}"]`; + if (hasNavigated) { + selector += `[navigated="true"]`; + } + const nodeFront = await getNodeFront(selector, inspector); + await selectNode(nodeFront, inspector); + const browserContainer = inspector.markup.getContainer(nodeFront); + return !!browserContainer; + }; + + info("Select the test browser in the Browser Toolbox (before navigation)"); + const hasContainerBeforeNavigation = await ToolboxTask.spawn( + { scope: browserToolboxScope, hasNavigated: false }, + hasBrowserContainerTask + ); + ok( + hasContainerBeforeNavigation, + "Found a valid container for the browser element before navigation" + ); + + info("Navigate the test tab to another data-uri"); + await navigateTo( + `data:text/html,<div>NAVIGATE TEST - AFTER: ${browserToolboxScope}</div>` + ); + tab.linkedBrowser.setAttribute("navigated", "true"); + + info("Select the test browser in the Browser Toolbox (after navigation)"); + const hasContainerAfterNavigation = await ToolboxTask.spawn( + { scope: browserToolboxScope, hasNavigated: true }, + hasBrowserContainerTask + ); + ok( + hasContainerAfterNavigation, + "Found a valid container for the browser element after navigation" + ); + + await ToolboxTask.destroy(); +} diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_netmonitor.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_netmonitor.js new file mode 100644 index 0000000000..56e38998ce --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_netmonitor.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* global gToolbox */ + +add_task(async function () { + // Disable several prefs to avoid network requests. + await pushPref("browser.safebrowsing.blockedURIs.enabled", false); + await pushPref("browser.safebrowsing.downloads.enabled", false); + await pushPref("browser.safebrowsing.malware.enabled", false); + await pushPref("browser.safebrowsing.phishing.enabled", false); + await pushPref("privacy.query_stripping.enabled", false); + await pushPref("extensions.systemAddon.update.enabled", false); + + await pushPref("services.settings.server", "invalid://err"); + + // Define a set list of visible columns + await pushPref( + "devtools.netmonitor.visibleColumns", + JSON.stringify(["file", "url", "status"]) + ); + + // Force observice all processes to see the content process requests + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const ToolboxTask = await initBrowserToolboxTask(); + + await ToolboxTask.importFunctions({ + waitUntil, + }); + + await ToolboxTask.spawn(null, async () => { + const { resourceCommand } = gToolbox.commands; + + // Assert that the toolbox is not listening to network events + // before the netmonitor panel is opened. + is( + resourceCommand.isResourceWatched(resourceCommand.TYPES.NETWORK_EVENT), + false, + "The toolox is not watching for network event resources" + ); + + await gToolbox.selectTool("netmonitor"); + const monitor = gToolbox.getCurrentPanel(); + const { document, store, windowRequire } = monitor.panelWin; + + const Actions = windowRequire( + "devtools/client/netmonitor/src/actions/index" + ); + + store.dispatch(Actions.batchEnable(false)); + + await waitUntil( + () => !!document.querySelector(".request-list-empty-notice") + ); + + is( + resourceCommand.isResourceWatched(resourceCommand.TYPES.NETWORK_EVENT), + true, + "The network panel is now watching for network event resources" + ); + + const emptyListNotice = document.querySelector( + ".request-list-empty-notice" + ); + + ok( + !!emptyListNotice, + "An empty notice should be displayed when the frontend is opened." + ); + + is( + emptyListNotice.innerText, + "Perform a request to see detailed information about network activity.", + "The reload and perfomance analysis details should not be visible in the browser toolbox" + ); + + is( + store.getState().requests.requests.length, + 0, + "The requests should be empty when the frontend is opened." + ); + + ok( + !document.querySelector(".requests-list-network-summary-button"), + "The perfomance analysis button should not be visible in the browser toolbox" + ); + }); + + info("Trigger request in parent process and check that it shows up"); + await fetch("https://example.org/document-builder.sjs?html=fromParent"); + + await ToolboxTask.spawn(null, async () => { + const monitor = gToolbox.getCurrentPanel(); + const { document, store } = monitor.panelWin; + + await waitUntil( + () => !document.querySelector(".request-list-empty-notice") + ); + ok(true, "The empty notice is no longer displayed"); + is( + store.getState().requests.requests.length, + 1, + "There's 1 request in the store" + ); + + const requests = Array.from( + document.querySelectorAll("tbody .requests-list-column.requests-list-url") + ); + is(requests.length, 1, "One request displayed"); + is( + requests[0].textContent, + "https://example.org/document-builder.sjs?html=fromParent", + "Expected request is displayed" + ); + }); + + info("Trigger content process requests"); + const urlImg = `${URL_ROOT_SSL}test-image.png?fromContent&${Date.now()}-${Math.random()}`; + await addTab( + `https://example.com/document-builder.sjs?html=${encodeURIComponent( + `<img src='${urlImg}'>` + )}` + ); + + await ToolboxTask.spawn(urlImg, async innerUrlImg => { + const monitor = gToolbox.getCurrentPanel(); + const { document, store } = monitor.panelWin; + + await waitUntil(() => store.getState().requests.requests.length >= 3); + ok(true, "Expected content requests are displayed"); + + const requests = Array.from( + document.querySelectorAll("tbody .requests-list-column.requests-list-url") + ); + is(requests.length, 3, "Three requests displayed"); + ok( + requests[1].textContent.includes( + `https://example.com/document-builder.sjs` + ), + "Request for the tab is displayed" + ); + is( + requests[2].textContent, + innerUrlImg, + "Request for image image in tab is displayed" + ); + }); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_print_preview.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_print_preview.js new file mode 100644 index 0000000000..81ff0808fb --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_print_preview.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// Test that the MultiProcessBrowserToolbox can be opened when print preview is +// started, and can select elements in the print preview document. +add_task(async function () { + // Forces the Browser Toolbox to open on the inspector by default + await pushPref("devtools.browsertoolbox.panel", "inspector"); + + // Enable Multiprocess Browser Toolbox + await pushPref("devtools.browsertoolbox.scope", "everything"); + + // Open the tab *after* opening the Browser Toolbox in order to force creating the remote frames + // late and exercise frame target watching code. + await addTab(`data:text/html,<div id="test-div">PRINT PREVIEW TEST</div>`); + + info("Start the print preview for the current tab"); + document.getElementById("cmd_print").doCommand(); + + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ + getNodeFront, + getNodeFrontInFrames, + selectNode, + // selectNodeInFrames depends on selectNode, getNodeFront, getNodeFrontInFrames. + selectNodeInFrames, + }); + + const hasCloseButton = await ToolboxTask.spawn(null, async () => { + /* global gToolbox */ + const inspector = gToolbox.getPanel("inspector"); + + info("Select the #test-div element in the printpreview document"); + await selectNodeInFrames( + ['browser[printpreview="true"]', "#test-div"], + inspector + ); + return !!gToolbox.doc.getElementById("toolbox-close"); + }); + ok(!hasCloseButton, "Browser toolbox doesn't have a close button"); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_rtl.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_rtl.js new file mode 100644 index 0000000000..558be1a16c --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_rtl.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// Test that DevTools panels are rendered in "rtl" (right-to-left) in the Browser Toolbox. +add_task(async function () { + await pushPref("intl.l10n.pseudo", "bidi"); + + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({}); + + const dir = await ToolboxTask.spawn(null, async () => { + /* global gToolbox */ + const inspector = await gToolbox.selectTool("inspector"); + return inspector.panelDoc.dir; + }); + is(dir, "rtl", "Inspector panel has the expected direction"); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_ruleview_stylesheet.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_ruleview_stylesheet.js new file mode 100644 index 0000000000..60f30b44b4 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_ruleview_stylesheet.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// Check that CSS rules are displayed with the proper source label in the +// browser toolbox. +add_task(async function () { + // Forces the Browser Toolbox to open on the inspector by default + await pushPref("devtools.browsertoolbox.panel", "inspector"); + // Enable Multiprocess Browser Toolbox + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ + getNodeFront, + getNodeFrontInFrames, + getRuleViewLinkByIndex, + selectNode, + // selectNodeInFrames depends on selectNode, getNodeFront, getNodeFrontInFrames. + selectNodeInFrames, + waitUntil, + }); + + // This is a simple test page, which contains a <div> with a CSS rule `color: red` + // coming from a dedicated stylesheet. + const tab = await addTab( + `https://example.com/browser/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_ruleview_stylesheet.html` + ); + + // Set a custom attribute on the tab's browser, in order to easily select it in the markup view + tab.linkedBrowser.setAttribute("test-tab", "true"); + + info( + "Get the source label for a rule displayed in the Browser Toolbox ruleview" + ); + const sourceLabel = await ToolboxTask.spawn(null, async () => { + /* global gToolbox */ + const inspector = gToolbox.getPanel("inspector"); + + info("Select the rule view"); + const onSidebarSelect = inspector.sidebar.once("select"); + inspector.sidebar.select("ruleview"); + await onSidebarSelect; + + info("Select a DIV element in the test page"); + await selectNodeInFrames( + ['browser[remote="true"][test-tab]', "div"], + inspector + ); + + info("Retrieve the sourceLabel for the rule at index 1"); + const ruleView = inspector.getPanel("ruleview").view; + await waitUntil(() => getRuleViewLinkByIndex(ruleView, 1)); + const sourceLabelEl = getRuleViewLinkByIndex(ruleView, 1).querySelector( + ".ruleview-rule-source-label" + ); + + return sourceLabelEl.textContent; + }); + + is( + sourceLabel, + "style_browser_toolbox_ruleview_stylesheet.css:1", + "source label has the expected value in the ruleview" + ); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_shouldprocessupdates.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_shouldprocessupdates.js new file mode 100644 index 0000000000..6f5e18791b --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_shouldprocessupdates.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +add_task(async function () { + // Running devtools should prevent processing updates. By setting this + // environment variable and then inspecting it from the launched devtools + // process, we can witness update processing being skipped. + Services.env.set("MOZ_TEST_PROCESS_UPDATES", "1"); + + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({}); + + let result = await ToolboxTask.spawn(null, async () => { + const result = { + exists: Services.env.exists("MOZ_TEST_PROCESS_UPDATES"), + get: Services.env.get("MOZ_TEST_PROCESS_UPDATES"), + }; + // Log so that we have a hope of debugging. + console.log("result", result); + return JSON.stringify(result); + }); + + result = JSON.parse(result); + ok(result.exists, "MOZ_TEST_PROCESS_UPDATES exists in subprocess"); + is( + result.get, + "ShouldNotProcessUpdates(): DevToolsLaunching", + "MOZ_TEST_PROCESS_UPDATES is correct in subprocess" + ); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_unavailable_children.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_unavailable_children.js new file mode 100644 index 0000000000..5029c62306 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_unavailable_children.js @@ -0,0 +1,144 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// This test is used to test a badge displayed in the markup view under content +// browser elements when switching from Multi Process mode to Parent Process +// mode. + +add_task(async function () { + // Forces the Browser Toolbox to open on the inspector by default + await pushPref("devtools.browsertoolbox.panel", "inspector"); + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const tab = await addTab( + "https://example.com/document-builder.sjs?html=<div id=pick-me>Pickme" + ); + tab.linkedBrowser.setAttribute("test-tab", "true"); + + const ToolboxTask = await initBrowserToolboxTask(); + + await ToolboxTask.importFunctions({ + waitUntil, + getNodeFront, + selectNode, + }); + + const tabProcessID = + tab.linkedBrowser.browsingContext.currentWindowGlobal.osPid; + + const decodedTabURI = decodeURI(tab.linkedBrowser.currentURI.spec); + + await ToolboxTask.spawn( + [tabProcessID, isFissionEnabled(), decodedTabURI], + async (processID, _isFissionEnabled, tabURI) => { + /* global gToolbox */ + const inspector = gToolbox.getPanel("inspector"); + + info("Select the test browser element."); + await selectNode('browser[remote="true"][test-tab]', inspector); + + info("Retrieve the node front for selected node."); + const browserNodeFront = inspector.selection.nodeFront; + ok(!!browserNodeFront, "Retrieved a node front for the browser"); + is(browserNodeFront.displayName, "browser"); + + // Small helper to expand containers and return the child container + // matching the provided display name. + async function expandContainer(container, expectedChildName) { + info(`Expand the node expected to contain a ${expectedChildName}`); + await inspector.markup.expandNode(container.node); + await waitUntil(() => !!container.getChildContainers().length); + + const children = container + .getChildContainers() + .filter(child => child.node.displayName === expectedChildName); + is(children.length, 1); + return children[0]; + } + + info("Check that the corresponding markup view container has children"); + const browserContainer = inspector.markup.getContainer(browserNodeFront); + ok(browserContainer.hasChildren); + ok( + !browserContainer.node.childrenUnavailable, + "childrenUnavailable un-set" + ); + ok( + !browserContainer.elt.querySelector(".unavailable-children"), + "The unavailable badge is not displayed" + ); + + // Store the asserts as a helper to reuse it later in the test. + async function assertMarkupView() { + info("Check that the children are #document > html > body > div"); + let container = await expandContainer(browserContainer, "#document"); + container = await expandContainer(container, "html"); + container = await expandContainer(container, "body"); + container = await expandContainer(container, "div"); + + info("Select the #pick-me div"); + await selectNode(container.node, inspector); + is(inspector.selection.nodeFront.id, "pick-me"); + } + await assertMarkupView(); + + const parentProcessScope = gToolbox.doc.querySelector( + 'input[name="chrome-debug-mode"][value="parent-process"]' + ); + + info("Switch to parent process only scope"); + const onInspectorUpdated = inspector.once("inspector-updated"); + parentProcessScope.click(); + await onInspectorUpdated; + + // Note: `getChildContainers` returns null when the container has no + // children, instead of an empty array. + await waitUntil(() => browserContainer.getChildContainers() === null); + + ok(!browserContainer.hasChildren, "browser container has no children"); + ok(browserContainer.node.childrenUnavailable, "childrenUnavailable set"); + ok( + !!browserContainer.elt.querySelector(".unavailable-children"), + "The unavailable badge is displayed" + ); + + const everythingScope = gToolbox.doc.querySelector( + 'input[name="chrome-debug-mode"][value="everything"]' + ); + + info("Switch to multi process scope"); + everythingScope.click(); + + info("Wait until browserContainer has children"); + await waitUntil(() => browserContainer.hasChildren); + ok(browserContainer.hasChildren, "browser container has children"); + ok( + !browserContainer.node.childrenUnavailable, + "childrenUnavailable un-set" + ); + ok( + !browserContainer.elt.querySelector(".unavailable-children"), + "The unavailable badge is no longer displayed" + ); + + await assertMarkupView(); + } + ); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_watchedByDevTools.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_watchedByDevTools.js new file mode 100644 index 0000000000..0eadaaeffe --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_watchedByDevTools.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that the "watchedByDevTools" flag is properly handled. + */ + +const EXAMPLE_NET_URI = + "https://example.net/document-builder.sjs?html=<div id=net>net"; +const EXAMPLE_COM_URI = + "https://example.com/document-builder.sjs?html=<div id=com>com"; +const EXAMPLE_ORG_URI = + "https://example.org/document-builder.sjs?headers=Cross-Origin-Opener-Policy:same-origin&html=<div id=org>org</div>"; + +add_task(async function () { + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const topWindow = Services.wm.getMostRecentWindow("navigator:browser"); + is( + topWindow.browsingContext.watchedByDevTools, + false, + "watchedByDevTools isn't set on the parent process browsing context when DevTools aren't opened" + ); + + // Open 2 tabs that we can check the flag on + const tabNet = await addTab(EXAMPLE_NET_URI); + const tabCom = await addTab(EXAMPLE_COM_URI); + + is( + tabNet.linkedBrowser.browsingContext.watchedByDevTools, + false, + "watchedByDevTools is not set on the .net tab" + ); + is( + tabCom.linkedBrowser.browsingContext.watchedByDevTools, + false, + "watchedByDevTools is not set on the .com tab" + ); + + info("Open the BrowserToolbox so the parent process will be watched"); + const ToolboxTask = await initBrowserToolboxTask(); + + is( + topWindow.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is set when the browser toolbox is opened" + ); + + // Open a new tab when the browser toolbox is opened + const newTab = await addTab(EXAMPLE_COM_URI); + + is( + tabNet.linkedBrowser.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is set on the .net tab browsing context after opening the browser toolbox" + ); + is( + tabCom.linkedBrowser.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is set on the .com tab browsing context after opening the browser toolbox" + ); + + info( + "Check that adding watchedByDevTools is set on a tab that was added when the browser toolbox was opened" + ); + is( + newTab.linkedBrowser.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is set on the newly opened tab" + ); + + info( + "Check that watchedByDevTools persist when navigating to a page that creates a new browsing context" + ); + const previousBrowsingContextId = newTab.linkedBrowser.browsingContext.id; + const onBrowserLoaded = BrowserTestUtils.browserLoaded( + newTab.linkedBrowser, + false, + encodeURI(EXAMPLE_ORG_URI) + ); + BrowserTestUtils.startLoadingURIString(newTab.linkedBrowser, EXAMPLE_ORG_URI); + await onBrowserLoaded; + + isnot( + newTab.linkedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + newTab.linkedBrowser.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is still set after navigating the tab to a page which forces a new browsing context" + ); + + info("Destroying browser toolbox"); + await ToolboxTask.destroy(); + + is( + topWindow.browsingContext.watchedByDevTools, + false, + "watchedByDevTools was reset when the browser toolbox was closed" + ); + + is( + tabNet.linkedBrowser.browsingContext.watchedByDevTools, + false, + "watchedByDevTools was reset on the .net tab after closing the browser toolbox" + ); + is( + tabCom.linkedBrowser.browsingContext.watchedByDevTools, + false, + "watchedByDevTools was reset on the .com tab after closing the browser toolbox" + ); + is( + newTab.linkedBrowser.browsingContext.watchedByDevTools, + false, + "watchedByDevTools was reset on the tab opened while the browser toolbox was opened" + ); +}); diff --git a/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_frame.html b/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_frame.html new file mode 100644 index 0000000000..1f365cc17f --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_frame.html @@ -0,0 +1,14 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Frame for browser_browser_toolbox_fission_contentframe_inspector.js</title> + </head> + + <body> + <div id="inside-iframe" test-attribute="fission">Inside iframe</div> + </body> +</html> diff --git a/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_page.html b/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_page.html new file mode 100644 index 0000000000..853c4ec91c --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_page.html @@ -0,0 +1,16 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Frame for browser_browser_toolbox_fission_contentframe_inspector.js</title> + </head> + + <body> + <!-- Here we use example.org, while the embedder is loaded with example.com (.org vs .com) + This ensures this frame will be a remote frame when fission is enabled. --> + <iframe src="https://example.org/browser/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_frame.html"></iframe> + </body> +</html> diff --git a/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_ruleview_stylesheet.html b/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_ruleview_stylesheet.html new file mode 100644 index 0000000000..3fab2ff8a8 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_ruleview_stylesheet.html @@ -0,0 +1,12 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <link rel="stylesheet" type="text/css" href="style_browser_toolbox_ruleview_stylesheet.css"> + </head> + <body> + <div>test div with "color: red" applied from a stylesheet</div> + </body> +</html> diff --git a/devtools/client/framework/browser-toolbox/test/head.js b/devtools/client/framework/browser-toolbox/test/head.js new file mode 100644 index 0000000000..4ea18d547e --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/head.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// shared-head.js handles imports, constants, and utility functions +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/framework/browser-toolbox/test/helpers-browser-toolbox.js", + this +); diff --git a/devtools/client/framework/browser-toolbox/test/helpers-browser-toolbox.js b/devtools/client/framework/browser-toolbox/test/helpers-browser-toolbox.js new file mode 100644 index 0000000000..bc97c20c01 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/helpers-browser-toolbox.js @@ -0,0 +1,233 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable no-unused-vars, no-undef */ + +"use strict"; + +const { BrowserToolboxLauncher } = ChromeUtils.importESModule( + "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs" +); +const { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); + +/** + * Open up a browser toolbox and return a ToolboxTask object for interacting + * with it. ToolboxTask has the following methods: + * + * importFunctions(object) + * + * The object contains functions from this process which should be defined in + * the global evaluation scope of the toolbox. The toolbox cannot load testing + * files directly. + * + * destroy() + * + * Destroy the browser toolbox and make sure it exits cleanly. + * + * @param {Object}: + * - {Function} existingProcessClose: if truth-y, connect to an existing + * browser toolbox process rather than launching a new one and + * connecting to it. The given function is expected to return an + * object containing an `exitCode`, like `{exitCode}`, and will be + * awaited in the returned `destroy()` function. `exitCode` is + * asserted to be 0 (success). + */ +async function initBrowserToolboxTask({ existingProcessClose } = {}) { + if (AppConstants.ASAN) { + ok( + false, + "ToolboxTask cannot be used on ASAN builds. This test should be skipped (Bug 1591064)." + ); + } + + await pushPref("devtools.chrome.enabled", true); + await pushPref("devtools.debugger.remote-enabled", true); + await pushPref("devtools.browsertoolbox.enable-test-server", true); + await pushPref("devtools.debugger.prompt-connection", false); + + // This rejection seems to affect all tests using the browser toolbox. + ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" + ).PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + + let process; + let dbgProcess; + if (!existingProcessClose) { + [process, dbgProcess] = await new Promise(resolve => { + BrowserToolboxLauncher.init({ + onRun: (_process, _dbgProcess) => resolve([_process, _dbgProcess]), + overwritePreferences: true, + }); + }); + ok(true, "Browser toolbox started"); + is( + BrowserToolboxLauncher.getBrowserToolboxSessionState(), + true, + "Has session state" + ); + } else { + ok(true, "Connecting to existing browser toolbox"); + } + + // The port of the DevToolsServer installed in the toolbox process is fixed. + // See browser-toolbox/window.js + let transport; + while (true) { + try { + transport = await DevToolsClient.socketConnect({ + host: "localhost", + port: 6001, + webSocket: false, + }); + break; + } catch (e) { + await waitForTime(100); + } + } + ok(true, "Got transport"); + + const client = new DevToolsClient(transport); + await client.connect(); + + const commands = await CommandsFactory.forMainProcess({ client }); + const target = await commands.descriptorFront.getTarget(); + const consoleFront = await target.getFront("console"); + + ok(true, "Connected"); + + await importFunctions({ + info: msg => dump(msg + "\n"), + is: (a, b, description) => { + let msg = + "'" + JSON.stringify(a) + "' is equal to '" + JSON.stringify(b) + "'"; + if (description) { + msg += " - " + description; + } + if (a !== b) { + msg = "FAILURE: " + msg; + dump(msg + "\n"); + throw new Error(msg); + } else { + msg = "SUCCESS: " + msg; + dump(msg + "\n"); + } + }, + ok: (a, description) => { + let msg = "'" + JSON.stringify(a) + "' is true"; + if (description) { + msg += " - " + description; + } + if (!a) { + msg = "FAILURE: " + msg; + dump(msg + "\n"); + throw new Error(msg); + } else { + msg = "SUCCESS: " + msg; + dump(msg + "\n"); + } + }, + }); + + async function evaluateExpression(expression, options = {}) { + const onEvaluationResult = consoleFront.once("evaluationResult"); + await consoleFront.evaluateJSAsync({ text: expression, ...options }); + return onEvaluationResult; + } + + /** + * Invoke the given function and argument(s) within the global evaluation scope + * of the toolbox. The evaluation scope predefines the name "gToolbox" for the + * toolbox itself. + * + * @param {value|Array<value>} arg + * If an Array is passed, we will consider it as the list of arguments + * to pass to `fn`. Otherwise we will consider it as the unique argument + * to pass to it. + * @param {Function} fn + * Function to call in the global scope within the browser toolbox process. + * This function will be stringified and passed to the process via RDP. + * @return {Promise<Value>} + * Return the primitive value returned by `fn`. + */ + async function spawn(arg, fn) { + // Use JSON.stringify to ensure that we can pass strings + // as well as any JSON-able object. + const argString = JSON.stringify(Array.isArray(arg) ? arg : [arg]); + const rv = await evaluateExpression(`(${fn}).apply(null,${argString})`, { + // Use the following argument in order to ensure waiting for the completion + // of the promise returned by `fn` (in case this is an async method). + mapped: { await: true }, + }); + if (rv.exceptionMessage) { + throw new Error(`ToolboxTask.spawn failure: ${rv.exceptionMessage}`); + } else if (rv.topLevelAwaitRejected) { + throw new Error(`ToolboxTask.spawn await rejected`); + } + return rv.result; + } + + async function importFunctions(functions) { + for (const [key, fn] of Object.entries(functions)) { + await evaluateExpression(`this.${key} = ${fn}`); + } + } + + async function importScript(script) { + const response = await evaluateExpression(script); + if (response.hasException) { + ok( + false, + "ToolboxTask.spawn exception while importing script: " + + response.exceptionMessage + ); + } + } + + let destroyed = false; + async function destroy() { + // No need to do anything if `destroy` was already called. + if (destroyed) { + return; + } + + const closePromise = existingProcessClose + ? existingProcessClose() + : dbgProcess.wait(); + evaluateExpression("gToolbox.destroy()").catch(e => { + // Ignore connection close as the toolbox destroy may destroy + // everything quickly enough so that evaluate request is still pending + if (!e.message.includes("Connection closed")) { + throw e; + } + }); + + const { exitCode } = await closePromise; + ok(true, "Browser toolbox process closed"); + + is(exitCode, 0, "The remote debugger process died cleanly"); + + if (!existingProcessClose) { + is( + BrowserToolboxLauncher.getBrowserToolboxSessionState(), + false, + "No session state after closing" + ); + } + + await commands.destroy(); + destroyed = true; + } + + // When tests involving using this task fail, the spawned Browser Toolbox is not + // destroyed and might impact the next tests (e.g. pausing the content process before + // the debugger from the content toolbox does). So make sure to cleanup everything. + registerCleanupFunction(destroy); + + return { + importFunctions, + importScript, + spawn, + destroy, + }; +} diff --git a/devtools/client/framework/browser-toolbox/test/style_browser_toolbox_ruleview_stylesheet.css b/devtools/client/framework/browser-toolbox/test/style_browser_toolbox_ruleview_stylesheet.css new file mode 100644 index 0000000000..538fa56f4a --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/style_browser_toolbox_ruleview_stylesheet.css @@ -0,0 +1,3 @@ +div { + color: red; +} diff --git a/devtools/client/framework/browser-toolbox/window.css b/devtools/client/framework/browser-toolbox/window.css new file mode 100644 index 0000000000..367f6364d2 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/window.css @@ -0,0 +1,41 @@ +/* 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/. */ + +body { + padding: 0; + margin: 0; + display: flex; + height: 100vh; +} + +/** + * The main content of the BrowserToolbox runs within an iframe. + */ +.devtools-toolbox-browsertoolbox-iframe { + border: 0; + width: 100%; +} + +/** + * Status message shows connection (to the backend) info messages. + */ +#status-message-container { + width: calc(100% - 10px); + font-family: var(--monospace-font-family); + padding: 5px; + color: FieldText; + background-color: Field; +} + +#status-message-title { + font-size: 14px; + font-weight: bold; +} + +#status-message { + font-size: 12px; + width: 100%; + height: 200px; + overflow: auto; +} diff --git a/devtools/client/framework/browser-toolbox/window.html b/devtools/client/framework/browser-toolbox/window.html new file mode 100644 index 0000000000..0f83dab775 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/window.html @@ -0,0 +1,29 @@ +<!-- 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/. --> +<!DOCTYPE html> +<html + id="devtools-toolbox-window" + windowtype="devtools:toolbox" + width="900" + height="450" + persist="screenX screenY width height sizemode" +> + <head> + <link rel="stylesheet" href="chrome://global/skin/global.css" /> + <link rel="stylesheet" href="chrome://devtools/skin/common.css" /> + <link + rel="stylesheet" + href="chrome://devtools/content/framework/browser-toolbox/window.css" + /> + <script src="chrome://devtools/content/framework/browser-toolbox/window.js"></script> + <script src="chrome://global/content/viewSourceUtils.js"></script> + <script src="chrome://browser/content/utilityOverlay.js"></script> + </head> + <body> + <div id="status-message-container" hidden> + <div id="status-message-title"></div> + <pre id="status-message"></pre> + </div> + </body> +</html> diff --git a/devtools/client/framework/browser-toolbox/window.js b/devtools/client/framework/browser-toolbox/window.js new file mode 100644 index 0000000000..e84ef02829 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/window.js @@ -0,0 +1,336 @@ +/* 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"; + +var { loader, require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); + +var { useDistinctSystemPrincipalLoader, releaseDistinctSystemPrincipalLoader } = + ChromeUtils.importESModule( + "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs" + ); + +// Require this module to setup core modules +loader.require("resource://devtools/client/framework/devtools-browser.js"); + +var { gDevTools } = require("resource://devtools/client/framework/devtools.js"); +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); +var { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); +var { PrefsHelper } = require("resource://devtools/client/shared/prefs.js"); +const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js"); +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + BrowserToolboxLauncher: + "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs", +}); + +const { + CommandsFactory, +} = require("resource://devtools/shared/commands/commands-factory.js"); + +// Timeout to wait before we assume that a connect() timed out without an error. +// In milliseconds. (With the Debugger pane open, this has been reported to last +// more than 10 seconds!) +const STATUS_REVEAL_TIME = 15000; + +/** + * Shortcuts for accessing various debugger preferences. + */ +var Prefs = new PrefsHelper("devtools.debugger", { + chromeDebuggingHost: ["Char", "chrome-debugging-host"], + chromeDebuggingWebSocket: ["Bool", "chrome-debugging-websocket"], +}); + +var gCommands, gToolbox, gShortcuts; + +function appendStatusMessage(msg) { + const statusMessage = document.getElementById("status-message"); + statusMessage.textContent += msg + "\n"; + if (msg.stack) { + statusMessage.textContent += msg.stack + "\n"; + } +} + +function toggleStatusMessage(visible = true) { + document.getElementById("status-message-container").hidden = !visible; +} + +function revealStatusMessage() { + toggleStatusMessage(true); +} + +function hideStatusMessage() { + toggleStatusMessage(false); +} + +var connect = async function () { + // Initiate the connection + + // MOZ_BROWSER_TOOLBOX_INPUT_CONTEXT is set by the target Firefox instance + // before opening the Browser Toolbox. + // If "devtools.webconsole.input.context" is true, the variable is set to "1", + // otherwise it is set to "0". + Services.prefs.setBoolPref( + "devtools.webconsole.input.context", + Services.env.get("MOZ_BROWSER_TOOLBOX_INPUT_CONTEXT") === "1" + ); + // Similar, but for the Browser Toolbox mode + if (Services.env.get("MOZ_BROWSER_TOOLBOX_FORCE_MULTIPROCESS") === "1") { + Services.prefs.setCharPref("devtools.browsertoolbox.scope", "everything"); + } + + const port = Services.env.get("MOZ_BROWSER_TOOLBOX_PORT"); + + // A port needs to be passed in from the environment, for instance: + // MOZ_BROWSER_TOOLBOX_PORT=6080 ./mach run -chrome \ + // chrome://devtools/content/framework/browser-toolbox/window.html + if (!port) { + throw new Error( + "Must pass a port in an env variable with MOZ_BROWSER_TOOLBOX_PORT" + ); + } + + const host = Prefs.chromeDebuggingHost; + const webSocket = Prefs.chromeDebuggingWebSocket; + appendStatusMessage(`Connecting to ${host}:${port}, ws: ${webSocket}`); + const transport = await DevToolsClient.socketConnect({ + host, + port, + webSocket, + }); + const client = new DevToolsClient(transport); + appendStatusMessage("Start protocol client for connection"); + await client.connect(); + + appendStatusMessage("Get root form for toolbox"); + gCommands = await CommandsFactory.forMainProcess({ client }); + + // Bug 1794607: for some unexpected reason, closing the DevToolsClient + // when the commands is destroyed by the toolbox would introduce leaks + // when running the browser-toolbox mochitests. + gCommands.shouldCloseClient = false; + + await openToolbox(gCommands); +}; + +// Certain options should be toggled since we can assume chrome debugging here +function setPrefDefaults() { + Services.prefs.setBoolPref("devtools.inspector.showUserAgentStyles", true); + Services.prefs.setBoolPref( + "devtools.inspector.showAllAnonymousContent", + true + ); + Services.prefs.setBoolPref("browser.dom.window.dump.enabled", true); + Services.prefs.setBoolPref("devtools.console.stdout.chrome", true); + Services.prefs.setBoolPref( + "devtools.command-button-noautohide.enabled", + true + ); + + // We force enabling the performance panel in the browser toolbox. + Services.prefs.setBoolPref("devtools.performance.enabled", true); + + // Bug 1773226: Try to avoid session restore to reopen a transient browser window + // if we ever opened a URL from the browser toolbox. (but it doesn't seem to be enough) + Services.prefs.setBoolPref("browser.sessionstore.resume_from_crash", false); + + // Disable Safe mode as the browser toolbox is often closed brutaly by subprocess + // and the safe mode kicks in when reopening it + Services.prefs.setIntPref("toolkit.startup.max_resumed_crashes", -1); +} + +window.addEventListener( + "load", + async function () { + gShortcuts = new KeyShortcuts({ window }); + gShortcuts.on("CmdOrCtrl+W", onCloseCommand); + gShortcuts.on("CmdOrCtrl+Alt+Shift+I", onDebugBrowserToolbox); + gShortcuts.on("CmdOrCtrl+Alt+R", onReloadBrowser); + + const statusMessageContainer = document.getElementById( + "status-message-title" + ); + statusMessageContainer.textContent = L10N.getStr( + "browserToolbox.statusMessage" + ); + + setPrefDefaults(); + + // Reveal status message if connecting is slow or if an error occurs. + const delayedStatusReveal = setTimeout( + revealStatusMessage, + STATUS_REVEAL_TIME + ); + try { + await connect(); + clearTimeout(delayedStatusReveal); + hideStatusMessage(); + } catch (e) { + clearTimeout(delayedStatusReveal); + appendStatusMessage(e); + revealStatusMessage(); + console.error(e); + } + }, + { once: true } +); + +function onCloseCommand(event) { + window.close(); +} + +/** + * Open a Browser toolbox debugging the current browser toolbox + * + * This helps debugging the browser toolbox code, especially the code + * running in the parent process. i.e. frontend code. + */ +function onDebugBrowserToolbox() { + lazy.BrowserToolboxLauncher.init(); +} + +/** + * Replicate the local-build-only key shortcut to reload the browser + */ +function onReloadBrowser() { + gToolbox.commands.targetCommand.reloadTopLevelTarget(); +} + +async function openToolbox(commands) { + const form = commands.descriptorFront._form; + appendStatusMessage( + `Create toolbox for target descriptor: ${JSON.stringify({ form }, null, 2)}` + ); + + // Remember the last panel that was used inside of this profile. + // But if we are testing, then it should always open the debugger panel. + const selectedTool = Services.prefs.getCharPref( + "devtools.browsertoolbox.panel", + Services.prefs.getCharPref("devtools.toolbox.selectedTool", "jsdebugger") + ); + + const toolboxOptions = { doc: document }; + appendStatusMessage(`Show toolbox with ${selectedTool} selected`); + + gToolbox = await gDevTools.showToolbox(commands, { + toolId: selectedTool, + hostType: Toolbox.HostType.BROWSERTOOLBOX, + hostOptions: toolboxOptions, + }); + + bindToolboxHandlers(); + + // Enable some testing features if the browser toolbox test pref is set. + if ( + Services.prefs.getBoolPref( + "devtools.browsertoolbox.enable-test-server", + false + ) + ) { + // setup a server so that the test can evaluate messages in this process. + installTestingServer(); + } + + await gToolbox.raise(); + + // Warn the user if we started recording this browser toolbox via MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP=1 + if (Services.env.get("MOZ_PROFILER_STARTUP") === "1") { + const notificationBox = gToolbox.getNotificationBox(); + const text = + "The profiler started recording this toolbox, open another browser toolbox to open the profile via the performance panel"; + notificationBox.appendNotification( + text, + null, + null, + notificationBox.PRIORITY_INFO_HIGH + ); + } +} + +let releaseTestLoader = null; +function installTestingServer() { + // Install a DevToolsServer in this process and inform the server of its + // location. Tests operating on the browser toolbox run in the server + // (the firefox parent process) and can connect to this new server using + // initBrowserToolboxTask(), allowing them to evaluate scripts here. + + const requester = {}; + const testLoader = useDistinctSystemPrincipalLoader(requester); + releaseTestLoader = () => releaseDistinctSystemPrincipalLoader(requester); + const { DevToolsServer } = testLoader.require( + "resource://devtools/server/devtools-server.js" + ); + const { SocketListener } = testLoader.require( + "resource://devtools/shared/security/socket.js" + ); + + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + DevToolsServer.allowChromeProcess = true; + + // Force this server to be kept alive until the browser toolbox process is closed. + // For some reason intermittents appears on Windows when destroying the server + // once the last connection drops. + DevToolsServer.keepAlive = true; + + // Use a fixed port which initBrowserToolboxTask can look for. + const socketOptions = { portOrPath: 6001 }; + const listener = new SocketListener(DevToolsServer, socketOptions); + listener.open(); +} + +async function bindToolboxHandlers() { + gToolbox.once("destroyed", quitApp); + window.addEventListener("unload", onUnload); + + // If the remote connection drops, firefox was closed + // In such case, force closing the browser toolbox + gCommands.client.once("closed", quitApp); + + if (Services.appinfo.OS == "Darwin") { + // Badge the dock icon to differentiate this process from the main application + // process. + updateBadgeText(false); + + gToolbox.on("toolbox-paused", () => updateBadgeText(true)); + gToolbox.on("toolbox-resumed", () => updateBadgeText(false)); + } +} + +function updateBadgeText(paused) { + const dockSupport = Cc["@mozilla.org/widget/macdocksupport;1"].getService( + Ci.nsIMacDockSupport + ); + dockSupport.badgeText = paused ? "▐▐ " : " ▶"; +} + +function onUnload() { + window.removeEventListener("unload", onUnload); + gToolbox.destroy(); + if (releaseTestLoader) { + releaseTestLoader(); + releaseTestLoader = null; + } +} + +function quitApp() { + const quit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers(quit, "quit-application-requested"); + + const shouldProceed = !quit.data; + if (shouldProceed) { + Services.startup.quit(Ci.nsIAppStartup.eForceQuit); + } +} |