diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/client/framework | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/framework')
270 files changed, 34080 insertions, 0 deletions
diff --git a/devtools/client/framework/actions/dom-mutation-breakpoints.js b/devtools/client/framework/actions/dom-mutation-breakpoints.js new file mode 100644 index 0000000000..1e1273d711 --- /dev/null +++ b/devtools/client/framework/actions/dom-mutation-breakpoints.js @@ -0,0 +1,155 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ +"use strict"; + +const { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + getDOMMutationBreakpoint, + getDOMMutationBreakpoints, +} = require("resource://devtools/client/framework/reducers/dom-mutation-breakpoints.js"); + +exports.registerWalkerListeners = registerWalkerListeners; +function registerWalkerListeners(store, walker) { + walker.on("mutations", mutations => handleWalkerMutations(mutations, store)); +} + +/** + * Called when a target is destroyed. This will allow the reducer to remove breakpoints on + * nodeFront associated with the passed target + * + * @param {ToolboxStore} store: The toolbox redux store + * @param {TargetFront} targetFront + */ +function removeTarget(store, targetFront) { + store.dispatch({ + type: "REMOVE_TARGET", + targetFront, + }); +} +exports.removeTarget = removeTarget; + +function handleWalkerMutations(mutations, store) { + // If we got BP updates for detach/unload, we want to drop those nodes from + // the list of active DOM mutation breakpoints. We explicitly check these + // cases because BP updates could also happen due to explicitly API + // operations to add/remove bps. + const mutationItems = mutations.filter( + mutation => mutation.type === "mutationBreakpoint" + ); + if (mutationItems.length) { + store.dispatch(updateBreakpointsForMutations(mutationItems)); + } +} + +exports.createDOMMutationBreakpoint = createDOMMutationBreakpoint; +function createDOMMutationBreakpoint(nodeFront, mutationType) { + assert(typeof nodeFront === "object" && nodeFront); + assert(typeof mutationType === "string"); + + return async function ({ dispatch, getState }) { + const walker = nodeFront.walkerFront; + + dispatch({ + type: "ADD_DOM_MUTATION_BREAKPOINT", + nodeFront, + mutationType, + }); + + await walker.setMutationBreakpoints(nodeFront, { + [mutationType]: true, + }); + }; +} + +exports.deleteDOMMutationBreakpoint = deleteDOMMutationBreakpoint; +function deleteDOMMutationBreakpoint(nodeFront, mutationType) { + assert(typeof nodeFront === "object" && nodeFront); + assert(typeof mutationType === "string"); + + return async function ({ dispatch, getState }) { + const walker = nodeFront.walkerFront; + await walker.setMutationBreakpoints(nodeFront, { + [mutationType]: false, + }); + + dispatch({ + type: "REMOVE_DOM_MUTATION_BREAKPOINT", + nodeFront, + mutationType, + }); + }; +} + +function updateBreakpointsForMutations(mutationItems) { + return async function ({ dispatch, getState }) { + const removedNodeFronts = []; + const changedNodeFronts = new Set(); + + for (const { target: nodeFront, mutationReason } of mutationItems) { + switch (mutationReason) { + case "api": + changedNodeFronts.add(nodeFront); + break; + default: + console.error( + "Unexpected mutation reason", + mutationReason, + ", removing" + ); + // Fall Through + case "detach": + case "unload": + removedNodeFronts.push(nodeFront); + break; + } + } + + if (removedNodeFronts.length) { + dispatch({ + type: "REMOVE_DOM_MUTATION_BREAKPOINTS_FOR_FRONTS", + nodeFronts: removedNodeFronts, + }); + } + if (changedNodeFronts.size > 0) { + const enabledStates = []; + for (const { + id, + nodeFront, + mutationType, + enabled, + } of getDOMMutationBreakpoints(getState())) { + if (changedNodeFronts.has(nodeFront)) { + const bpEnabledOnFront = nodeFront.mutationBreakpoints[mutationType]; + if (bpEnabledOnFront !== enabled) { + // Sync the bp state from the front into the store. + enabledStates.push([id, bpEnabledOnFront]); + } + } + } + + dispatch({ + type: "SET_DOM_MUTATION_BREAKPOINTS_ENABLED_STATE", + enabledStates, + }); + } + }; +} + +exports.toggleDOMMutationBreakpointState = toggleDOMMutationBreakpointState; +function toggleDOMMutationBreakpointState(id, enabled) { + assert(typeof id === "string"); + assert(typeof enabled === "boolean"); + + return async function ({ dispatch, getState }) { + const bp = getDOMMutationBreakpoint(getState(), id); + if (!bp) { + throw new Error(`No DOM mutation BP with ID ${id}`); + } + + const walker = bp.nodeFront.getParent(); + await walker.setMutationBreakpoints(bp.nodeFront, { + [bp.mutationType]: enabled, + }); + }; +} diff --git a/devtools/client/framework/actions/index.js b/devtools/client/framework/actions/index.js new file mode 100644 index 0000000000..29da67d38c --- /dev/null +++ b/devtools/client/framework/actions/index.js @@ -0,0 +1,8 @@ +/* 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"; + +module.exports = { + ...require("resource://devtools/client/framework/actions/dom-mutation-breakpoints.js"), +}; diff --git a/devtools/client/framework/actions/moz.build b/devtools/client/framework/actions/moz.build new file mode 100644 index 0000000000..e77a7cc2cc --- /dev/null +++ b/devtools/client/framework/actions/moz.build @@ -0,0 +1,11 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "dom-mutation-breakpoints.js", + "index.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Framework") diff --git a/devtools/client/framework/browser-menus.js b/devtools/client/framework/browser-menus.js new file mode 100644 index 0000000000..bc070fc78d --- /dev/null +++ b/devtools/client/framework/browser-menus.js @@ -0,0 +1,340 @@ +/* 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"; + +/** + * This module inject dynamically menu items into browser UI. + * + * Menu definitions are fetched from: + * - devtools/client/menus for top level entires + * - devtools/client/definitions for tool-specifics entries + */ + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const MENUS_L10N = new LocalizationHelper( + "devtools/client/locales/menus.properties" +); + +loader.lazyRequireGetter( + this, + "gDevTools", + "resource://devtools/client/framework/devtools.js", + true +); +loader.lazyRequireGetter( + this, + "gDevToolsBrowser", + "resource://devtools/client/framework/devtools-browser.js", + true +); +loader.lazyRequireGetter( + this, + "Telemetry", + "resource://devtools/client/shared/telemetry.js" +); + +let telemetry = null; + +// Keep list of inserted DOM Elements in order to remove them on unload +// Maps browser xul document => list of DOM Elements +const FragmentsCache = new Map(); + +function l10n(key) { + return MENUS_L10N.getStr(key); +} + +/** + * Create a xul:menuitem element + * + * @param {HTMLDocument} doc + * The document to which menus are to be added. + * @param {String} id + * Element id. + * @param {String} label + * Menu label. + * @param {String} accesskey (optional) + * Access key of the menuitem, used as shortcut while opening the menu. + * @param {Boolean} isCheckbox (optional) + * If true, the menuitem will act as a checkbox and have an optional + * tick on its left. + * @param {String} appMenuL10nId (optional) + * A Fluent key to set the appmenu-data-l10n-id attribute of the menuitem + * to. This can then be used to show a different string when cloning the + * menuitem to show in the AppMenu or panel contexts. + * + * @return XULMenuItemElement + */ +function createMenuItem({ + doc, + id, + label, + accesskey, + isCheckbox, + appMenuL10nId, +}) { + const menuitem = doc.createXULElement("menuitem"); + menuitem.id = id; + menuitem.setAttribute("label", label); + if (accesskey) { + menuitem.setAttribute("accesskey", accesskey); + } + if (isCheckbox) { + menuitem.setAttribute("type", "checkbox"); + menuitem.setAttribute("autocheck", "false"); + } + if (appMenuL10nId) { + menuitem.setAttribute("appmenu-data-l10n-id", appMenuL10nId); + } + return menuitem; +} + +/** + * Add a menu entry for a tool definition + * + * @param {Object} toolDefinition + * Tool definition of the tool to add a menu entry. + * @param {HTMLDocument} doc + * The document to which the tool menu item is to be added. + */ +function createToolMenuElements(toolDefinition, doc) { + const id = toolDefinition.id; + const menuId = "menuitem_" + id; + + // Prevent multiple entries for the same tool. + if (doc.getElementById(menuId)) { + return null; + } + + const oncommand = async function (id, event) { + try { + const window = event.target.ownerDocument.defaultView; + await gDevToolsBrowser.selectToolCommand(window, id, Cu.now()); + sendEntryPointTelemetry(window); + } catch (e) { + console.error(`Exception while opening ${id}: ${e}\n${e.stack}`); + } + }.bind(null, id); + + const menuitem = createMenuItem({ + doc, + id: "menuitem_" + id, + label: toolDefinition.menuLabel || toolDefinition.label, + accesskey: toolDefinition.accesskey, + appMenuL10nId: toolDefinition.appMenuL10nId, + }); + // Refer to the key in order to display the key shortcut at menu ends + // This <key> element is being created by devtools/client/devtools-startup.js + menuitem.setAttribute("key", "key_" + id); + menuitem.addEventListener("command", oncommand); + + return menuitem; +} + +/** + * Send entry point telemetry explaining how the devtools were launched when + * launched from the System Menu.. This functionality also lives inside + * `devtools/startup/devtools-startup.js` but that codepath is only used the + * first time a toolbox is opened for a tab. + */ +function sendEntryPointTelemetry(window) { + if (!telemetry) { + telemetry = new Telemetry(); + } + + telemetry.addEventProperty(window, "open", "tools", null, "shortcut", ""); + + telemetry.addEventProperty( + window, + "open", + "tools", + null, + "entrypoint", + "SystemMenu" + ); +} + +/** + * Create xul menuitem, key elements for a given tool. + * And then insert them into browser DOM. + * + * @param {HTMLDocument} doc + * The document to which the tool is to be registered. + * @param {Object} toolDefinition + * Tool definition of the tool to register. + * @param {Object} prevDef + * The tool definition after which the tool menu item is to be added. + */ +function insertToolMenuElements(doc, toolDefinition, prevDef) { + const menuitem = createToolMenuElements(toolDefinition, doc); + if (!menuitem) { + return; + } + + let ref; + if (prevDef) { + const menuitem = doc.getElementById("menuitem_" + prevDef.id); + ref = menuitem?.nextSibling ? menuitem.nextSibling : null; + } else { + ref = doc.getElementById("menu_devtools_remotedebugging"); + } + + if (ref) { + ref.parentNode.insertBefore(menuitem, ref); + } +} +exports.insertToolMenuElements = insertToolMenuElements; + +/** + * Remove a tool's menuitem from a window + * + * @param {string} toolId + * Id of the tool to add a menu entry for + * @param {HTMLDocument} doc + * The document to which the tool menu item is to be removed from + */ +function removeToolFromMenu(toolId, doc) { + const key = doc.getElementById("key_" + toolId); + if (key) { + key.remove(); + } + + const menuitem = doc.getElementById("menuitem_" + toolId); + if (menuitem) { + menuitem.remove(); + } +} +exports.removeToolFromMenu = removeToolFromMenu; + +/** + * Add all tools to the developer tools menu of a window. + * + * @param {HTMLDocument} doc + * The document to which the tool items are to be added. + */ +function addAllToolsToMenu(doc) { + const fragMenuItems = doc.createDocumentFragment(); + + for (const toolDefinition of gDevTools.getToolDefinitionArray()) { + if (!toolDefinition.inMenu) { + continue; + } + + const menuItem = createToolMenuElements(toolDefinition, doc); + + if (!menuItem) { + continue; + } + + fragMenuItems.appendChild(menuItem); + } + + const mps = doc.getElementById("menu_devtools_remotedebugging"); + if (mps) { + mps.parentNode.insertBefore(fragMenuItems, mps); + } +} + +/** + * Add global menus that are not panel specific. + * + * @param {HTMLDocument} doc + * The document to which menus are to be added. + */ +function addTopLevelItems(doc) { + const menuItems = doc.createDocumentFragment(); + + const { menuitems } = require("resource://devtools/client/menus.js"); + for (const item of menuitems) { + if (item.separator) { + const separator = doc.createXULElement("menuseparator"); + separator.id = item.id; + menuItems.appendChild(separator); + } else { + const { id, l10nKey } = item; + + // Create a <menuitem> + const menuitem = createMenuItem({ + doc, + id, + label: l10n(l10nKey + ".label"), + accesskey: l10n(l10nKey + ".accesskey"), + isCheckbox: item.checkbox, + appMenuL10nId: item.appMenuL10nId, + }); + menuitem.addEventListener("command", item.oncommand); + menuItems.appendChild(menuitem); + + if (item.keyId) { + menuitem.setAttribute("key", "key_" + item.keyId); + } + } + } + + // Cache all nodes before insertion to be able to remove them on unload + const nodes = []; + for (const node of menuItems.children) { + nodes.push(node); + } + FragmentsCache.set(doc, nodes); + + const menu = doc.getElementById("menuWebDeveloperPopup"); + menu.appendChild(menuItems); + + // There is still "Page Source" and "Task Manager" menuitems hardcoded + // into browser.xhtml. Instead of manually inserting everything around it, + // move them to the expected position. + const pageSourceMenu = doc.getElementById("menu_pageSource"); + const extensionsForDevelopersMenu = doc.getElementById( + "extensionsForDevelopers" + ); + menu.insertBefore(pageSourceMenu, extensionsForDevelopersMenu); + + const taskManagerMenu = doc.getElementById("menu_taskManager"); + const remoteDebuggingMenu = doc.getElementById( + "menu_devtools_remotedebugging" + ); + menu.insertBefore(taskManagerMenu, remoteDebuggingMenu); +} + +/** + * Remove global menus that are not panel specific. + * + * @param {HTMLDocument} doc + * The document to which menus are to be added. + */ +function removeTopLevelItems(doc) { + const nodes = FragmentsCache.get(doc); + if (!nodes) { + return; + } + FragmentsCache.delete(doc); + for (const node of nodes) { + node.remove(); + } +} + +/** + * Add menus to a browser document + * + * @param {HTMLDocument} doc + * The document to which menus are to be added. + */ +exports.addMenus = function (doc) { + addTopLevelItems(doc); + + addAllToolsToMenu(doc); +}; + +/** + * Remove menus from a browser document + * + * @param {HTMLDocument} doc + * The document to which menus are to be removed. + */ +exports.removeMenus = function (doc) { + // We only remove top level entries. Per-tool entries are removed while + // unregistering each tool. + removeTopLevelItems(doc); +}; 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); + } +} diff --git a/devtools/client/framework/commands-from-url.js b/devtools/client/framework/commands-from-url.js new file mode 100644 index 0000000000..f9b5cec46c --- /dev/null +++ b/devtools/client/framework/commands-from-url.js @@ -0,0 +1,179 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +const { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); +const { + remoteClientManager, +} = require("resource://devtools/client/shared/remote-debugging/remote-client-manager.js"); +const { + CommandsFactory, +} = require("resource://devtools/shared/commands/commands-factory.js"); + +/** + * Construct a commands object for a given URL with various query parameters: + * + * - host, port & ws: See the documentation for clientFromURL + * + * - type: "tab", "extension", "worker" or "process" + * {String} The type of target to connect to. + * + * If type == "tab": + * - id: + * {Number} the tab browserId + * + * If type == "extension": + * - id: + * {String} the addonID of the webextension to debug. + * + * If type == "worker": + * - id: + * {String} the unique Worker id of the Worker to debug. + * + * If type == "process": + * - id: + * {Number} the process id to debug. Default to 0, which is the parent process. + * + * + * @param {URL} url + * The url to fetch query params from. + * + * @return A commands object + */ +exports.commandsFromURL = async function commandsFromURL(url) { + const client = await clientFromURL(url); + const params = url.searchParams; + + // Clients retrieved from the remote-client-manager are already connected. + const isCachedClient = params.get("remoteId"); + if (!isCachedClient) { + // Connect any other client. + await client.connect(); + } + + const id = params.get("id"); + const type = params.get("type"); + + let commands; + try { + commands = await _commandsFromURL(client, id, type); + } catch (e) { + if (!isCachedClient) { + // If the client was not cached, then the client was created here. If the target + // creation failed, we should close the client. + await client.close(); + } + throw e; + } + + // When opening about:debugging's toolboxes for remote runtimes, + // we create a new commands using a shared and cached client. + // Prevent closing the DevToolsClient on toolbox close and Commands destruction + // as this can be used by about:debugging and other toolboxes. + if (isCachedClient) { + commands.shouldCloseClient = false; + } + + return commands; +}; + +async function _commandsFromURL(client, id, type) { + if (!type) { + throw new Error("commandsFromURL, missing type parameter"); + } + + let commands; + if (type === "tab") { + // Fetch target for a remote tab + id = parseInt(id, 10); + if (isNaN(id)) { + throw new Error( + `commandsFromURL, wrong tab id '${id}', should be a number` + ); + } + try { + commands = await CommandsFactory.forRemoteTab(id, { client }); + } catch (ex) { + if (ex.message.startsWith("Protocol error (noTab)")) { + throw new Error( + `commandsFromURL, tab with browserId '${id}' doesn't exist` + ); + } + throw ex; + } + } else if (type === "extension") { + commands = await CommandsFactory.forAddon(id, { client }); + + if (!commands) { + throw new Error( + `commandsFromURL, extension with id '${id}' doesn't exist` + ); + } + } else if (type === "worker") { + commands = await CommandsFactory.forWorker(id, { client }); + + if (!commands) { + throw new Error(`commandsFromURL, worker with id '${id}' doesn't exist`); + } + } else if (type == "process") { + // When debugging firefox itself, force the server to accept debugging processes. + DevToolsServer.allowChromeProcess = true; + commands = await CommandsFactory.forMainProcess({ client }); + } else { + throw new Error(`commandsFromURL, unsupported type '${type}' parameter`); + } + + return commands; +} + +/** + * Create a DevToolsClient for a given URL object having various query parameters: + * + * host: + * {String} The hostname or IP address to connect to. + * port: + * {Number} The TCP port to connect to, to use with `host` argument. + * remoteId: + * {String} Remote client id, for runtimes from the remote-client-manager + * ws: + * {Boolean} If true, connect via websocket instead of regular TCP connection. + * + * @param {URL} url + * The url to fetch query params from. + * @return a promise that resolves a DevToolsClient object + */ +async function clientFromURL(url) { + const params = url.searchParams; + + // If a remote id was provided we should already have a connected client available. + const remoteId = params.get("remoteId"); + if (remoteId) { + const client = remoteClientManager.getClientByRemoteId(remoteId); + if (!client) { + throw new Error(`Could not find client with remote id: ${remoteId}`); + } + return client; + } + + const host = params.get("host"); + const port = params.get("port"); + const webSocket = !!params.get("ws"); + + let transport; + if (port) { + transport = await DevToolsClient.socketConnect({ host, port, webSocket }); + } else { + // Setup a server if we don't have one already running + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + transport = DevToolsServer.connectPipe(); + } + return new DevToolsClient(transport); +} diff --git a/devtools/client/framework/components/ChromeDebugToolbar.css b/devtools/client/framework/components/ChromeDebugToolbar.css new file mode 100644 index 0000000000..4b74d47e05 --- /dev/null +++ b/devtools/client/framework/components/ChromeDebugToolbar.css @@ -0,0 +1,60 @@ +/* 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/. */ + +.chrome-debug-toolbar { + display: flex; + padding: 0.5em 1em; + font-size: 12px; + line-height: 1.5; + background-color: var(--theme-body-alternate-emphasized-background); + font-family: system-ui, -apple-system, sans-serif; + border-block-end: 1px solid var(--theme-toolbar-separator); +} + +.chrome-debug-toolbar section > h3 { + margin: 0; + font-weight: normal; +} + +.chrome-debug-toolbar__modes { + display: flex; + align-items: baseline; + gap: 0.5em 1em; + flex-wrap: wrap; +} + +.chrome-debug-toolbar__modes label { + border: 1px solid var(--theme-toolbar-separator); + border-radius: 4px; + padding: 4px 8px; +} + +.chrome-debug-toolbar__modes label.selected { + border-color: var(--theme-toolbar-selected-color); +} + +.chrome-debug-toolbar__modes label:where(:hover, :focus-within) { + background-color: var(--blue-50-a30); +} + +.chrome-debug-toolbar__modes label input { + margin: 0; + margin-inline-end: 4px; +} + +.mode__sublabel { + color: var(--theme-comment); + margin-inline-start: 4px; +} + +@media (prefers-contrast) { + .chrome-debug-toolbar { + background-color: Window; + color: WindowText; + } + + .mode_sublabel { + color: GrayText; + } +} diff --git a/devtools/client/framework/components/ChromeDebugToolbar.js b/devtools/client/framework/components/ChromeDebugToolbar.js new file mode 100644 index 0000000000..b126cf78fd --- /dev/null +++ b/devtools/client/framework/components/ChromeDebugToolbar.js @@ -0,0 +1,123 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const FluentReact = require("resource://devtools/client/shared/vendor/fluent-react.js"); +const Localized = createFactory(FluentReact.Localized); + +const MODE_PREF = "devtools.browsertoolbox.scope"; + +const MODE_VALUES = { + PARENT_PROCESS: "parent-process", + EVERYTHING: "everything", +}; + +const MODE_DATA = { + [MODE_VALUES.PARENT_PROCESS]: { + containerL10nId: "toolbox-mode-parent-process-container", + labelL10nId: "toolbox-mode-parent-process-label", + subLabelL10nId: "toolbox-mode-parent-process-sub-label", + }, + [MODE_VALUES.EVERYTHING]: { + containerL10nId: "toolbox-mode-everything-container", + labelL10nId: "toolbox-mode-everything-label", + subLabelL10nId: "toolbox-mode-everything-sub-label", + }, +}; + +/** + * Toolbar displayed on top of the regular toolbar in the Browser Toolbox and Browser Console, + * displaying chrome-debugging-specific options. + */ +class ChromeDebugToolbar extends PureComponent { + static get propTypes() { + return { + isBrowserConsole: PropTypes.bool, + }; + } + + constructor(props) { + super(props); + + this.state = { + mode: Services.prefs.getCharPref(MODE_PREF), + }; + + this.onModePrefChanged = this.onModePrefChanged.bind(this); + Services.prefs.addObserver(MODE_PREF, this.onModePrefChanged); + } + + componentWillUnmount() { + Services.prefs.removeObserver(MODE_PREF, this.onModePrefChanged); + } + + onModePrefChanged() { + this.setState({ + mode: Services.prefs.getCharPref(MODE_PREF), + }); + } + + renderModeItem(value) { + const { containerL10nId, labelL10nId, subLabelL10nId } = MODE_DATA[value]; + + const checked = this.state.mode == value; + return Localized( + { + id: containerL10nId, + attrs: { title: true }, + }, + dom.label( + { + className: checked ? "selected" : null, + }, + dom.input({ + type: `radio`, + name: `chrome-debug-mode`, + value, + checked: checked || null, + onChange: () => { + Services.prefs.setCharPref(MODE_PREF, value); + }, + }), + Localized({ id: labelL10nId }, dom.span({ className: "mode__label" })), + Localized( + { id: subLabelL10nId }, + dom.span({ className: "mode__sublabel" }) + ) + ) + ); + } + + render() { + return dom.header( + { + className: "chrome-debug-toolbar", + }, + dom.section( + { + className: "chrome-debug-toolbar__modes", + }, + Localized( + { + id: this.props.isBrowserConsole + ? "toolbox-mode-browser-console-label" + : "toolbox-mode-browser-toolbox-label", + }, + dom.h3({}) + ), + this.renderModeItem(MODE_VALUES.PARENT_PROCESS), + this.renderModeItem(MODE_VALUES.EVERYTHING) + ) + ); + } +} + +module.exports = ChromeDebugToolbar; diff --git a/devtools/client/framework/components/DebugTargetErrorPage.css b/devtools/client/framework/components/DebugTargetErrorPage.css new file mode 100644 index 0000000000..ffac30cece --- /dev/null +++ b/devtools/client/framework/components/DebugTargetErrorPage.css @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.error-page { + --base-unit: 4px; /* from photon */ + + padding: calc(var(--base-unit) * 4); + font-size: 15px; /* from photon */ + min-height: 100vh; +} + +.error-page__title { + margin: 0; + font-size: 36px; /* from photon */ + font-weight: 200; /* from photon */ +} + +.error-page__details { + font-family: monospace; +} diff --git a/devtools/client/framework/components/DebugTargetErrorPage.js b/devtools/client/framework/components/DebugTargetErrorPage.js new file mode 100644 index 0000000000..9790b9cd7f --- /dev/null +++ b/devtools/client/framework/components/DebugTargetErrorPage.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +/** + * This component is displayed when the about:devtools-toolbox fails to load + * properly due to wrong parameters or debug targets that don't exist. + */ +class DebugTargetErrorPage extends PureComponent { + static get propTypes() { + return { + errorMessage: PropTypes.string.isRequired, + L10N: PropTypes.object.isRequired, + }; + } + + render() { + const { errorMessage, L10N } = this.props; + + return dom.article( + { + className: "error-page qa-error-page", + }, + dom.h1( + { + className: "error-page__title", + }, + L10N.getStr("toolbox.debugTargetErrorPage.title") + ), + dom.p({}, L10N.getStr("toolbox.debugTargetErrorPage.description")), + dom.output( + { + className: "error-page__details", + }, + errorMessage + ) + ); + } +} + +module.exports = DebugTargetErrorPage; diff --git a/devtools/client/framework/components/DebugTargetInfo.js b/devtools/client/framework/components/DebugTargetInfo.js new file mode 100644 index 0000000000..e3911e96c9 --- /dev/null +++ b/devtools/client/framework/components/DebugTargetInfo.js @@ -0,0 +1,401 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + CONNECTION_TYPES, +} = require("resource://devtools/client/shared/remote-debugging/constants.js"); +const DESCRIPTOR_TYPES = require("resource://devtools/client/fronts/descriptors/descriptor-types.js"); +const FluentReact = require("resource://devtools/client/shared/vendor/fluent-react.js"); +const Localized = createFactory(FluentReact.Localized); + +/** + * This is header that should be displayed on top of the toolbox when using + * about:devtools-toolbox. + */ +class DebugTargetInfo extends PureComponent { + static get propTypes() { + return { + alwaysOnTop: PropTypes.boolean.isRequired, + focusedState: PropTypes.boolean, + toggleAlwaysOnTop: PropTypes.func.isRequired, + debugTargetData: PropTypes.shape({ + connectionType: PropTypes.oneOf(Object.values(CONNECTION_TYPES)) + .isRequired, + runtimeInfo: PropTypes.shape({ + deviceName: PropTypes.string, + icon: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + version: PropTypes.string.isRequired, + }).isRequired, + descriptorType: PropTypes.oneOf(Object.values(DESCRIPTOR_TYPES)) + .isRequired, + }).isRequired, + L10N: PropTypes.object.isRequired, + toolbox: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { urlValue: props.toolbox.target.url }; + + this.onChange = this.onChange.bind(this); + this.onFocus = this.onFocus.bind(this); + this.onSubmit = this.onSubmit.bind(this); + } + + componentDidMount() { + this.updateTitle(); + } + + updateTitle() { + const { L10N, debugTargetData, toolbox } = this.props; + const title = toolbox.target.name; + const descriptorTypeStr = L10N.getStr( + this.getAssetsForDebugDescriptorType().l10nId + ); + + const { connectionType } = debugTargetData; + if (connectionType === CONNECTION_TYPES.THIS_FIREFOX) { + toolbox.doc.title = L10N.getFormatStr( + "toolbox.debugTargetInfo.tabTitleLocal", + descriptorTypeStr, + title + ); + } else { + const connectionTypeStr = L10N.getStr( + this.getAssetsForConnectionType().l10nId + ); + toolbox.doc.title = L10N.getFormatStr( + "toolbox.debugTargetInfo.tabTitleRemote", + connectionTypeStr, + descriptorTypeStr, + title + ); + } + } + + getRuntimeText() { + const { debugTargetData, L10N } = this.props; + const { name, version } = debugTargetData.runtimeInfo; + const { connectionType } = debugTargetData; + const brandShorterName = L10N.getStr("brandShorterName"); + + return connectionType === CONNECTION_TYPES.THIS_FIREFOX + ? L10N.getFormatStr( + "toolbox.debugTargetInfo.runtimeLabel.thisRuntime", + brandShorterName, + version + ) + : L10N.getFormatStr( + "toolbox.debugTargetInfo.runtimeLabel", + name, + version + ); + } + + getAssetsForConnectionType() { + const { connectionType } = this.props.debugTargetData; + + switch (connectionType) { + case CONNECTION_TYPES.USB: + return { + image: "chrome://devtools/skin/images/aboutdebugging-usb-icon.svg", + l10nId: "toolbox.debugTargetInfo.connection.usb", + }; + case CONNECTION_TYPES.NETWORK: + return { + image: "chrome://devtools/skin/images/aboutdebugging-globe-icon.svg", + l10nId: "toolbox.debugTargetInfo.connection.network", + }; + default: + return {}; + } + } + + getAssetsForDebugDescriptorType() { + const { descriptorType } = this.props.debugTargetData; + + // TODO: https://bugzilla.mozilla.org/show_bug.cgi?id=1520723 + // Show actual favicon (currently toolbox.target.activeTab.favicon + // is unpopulated) + const favicon = "chrome://devtools/skin/images/globe.svg"; + + switch (descriptorType) { + case DESCRIPTOR_TYPES.EXTENSION: + return { + image: "chrome://devtools/skin/images/debugging-addons.svg", + l10nId: "toolbox.debugTargetInfo.targetType.extension", + }; + case DESCRIPTOR_TYPES.PROCESS: + return { + image: "chrome://devtools/skin/images/settings.svg", + l10nId: "toolbox.debugTargetInfo.targetType.process", + }; + case DESCRIPTOR_TYPES.TAB: + return { + image: favicon, + l10nId: "toolbox.debugTargetInfo.targetType.tab", + }; + case DESCRIPTOR_TYPES.WORKER: + return { + image: "chrome://devtools/skin/images/debugging-workers.svg", + l10nId: "toolbox.debugTargetInfo.targetType.worker", + }; + default: + return {}; + } + } + + onChange({ target }) { + this.setState({ urlValue: target.value }); + } + + onFocus({ target }) { + target.select(); + } + + onSubmit(event) { + event.preventDefault(); + let url = this.state.urlValue; + + if (!url || !url.length) { + return; + } + + try { + // Get the URL from the fixup service: + const flags = Services.uriFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS; + const uriInfo = Services.uriFixup.getFixupURIInfo(url, flags); + url = uriInfo.fixedURI.spec; + } catch (ex) { + // The getFixupURIInfo service will throw an error if a malformed URI is + // produced from the input. + console.error(ex); + } + + this.props.toolbox.target.navigateTo({ url }); + } + + shallRenderConnection() { + const { connectionType } = this.props.debugTargetData; + const renderableTypes = [CONNECTION_TYPES.USB, CONNECTION_TYPES.NETWORK]; + + return renderableTypes.includes(connectionType); + } + + renderConnection() { + const { connectionType } = this.props.debugTargetData; + const { image, l10nId } = this.getAssetsForConnectionType(); + + return dom.span( + { + className: "iconized-label qa-connection-info", + }, + dom.img({ src: image, alt: `${connectionType} icon` }), + this.props.L10N.getStr(l10nId) + ); + } + + renderRuntime() { + if ( + !this.props.debugTargetData.runtimeInfo || + (this.props.debugTargetData.connectionType === + CONNECTION_TYPES.THIS_FIREFOX && + this.props.debugTargetData.descriptorType === + DESCRIPTOR_TYPES.EXTENSION) + ) { + // Skip the runtime render if no runtimeInfo is available. + // Runtime info is retrieved from the remote-client-manager, which might not be + // setup if about:devtools-toolbox was not opened from about:debugging. + // + // Also skip the runtime if we are debugging firefox itself, mainly to save some space. + return null; + } + + const { icon, deviceName } = this.props.debugTargetData.runtimeInfo; + + return dom.span( + { + className: "iconized-label qa-runtime-info", + }, + dom.img({ src: icon, className: "channel-icon qa-runtime-icon" }), + dom.b({ className: "devtools-ellipsis-text" }, this.getRuntimeText()), + dom.span({ className: "devtools-ellipsis-text" }, deviceName) + ); + } + + renderTargetTitle() { + const title = this.props.toolbox.target.name; + + const { image, l10nId } = this.getAssetsForDebugDescriptorType(); + + return dom.span( + { + className: "iconized-label debug-target-title", + }, + dom.img({ src: image, alt: this.props.L10N.getStr(l10nId) }), + title + ? dom.b({ className: "devtools-ellipsis-text qa-target-title" }, title) + : null + ); + } + + renderTargetURI() { + const url = this.props.toolbox.target.url; + const { descriptorType } = this.props.debugTargetData; + const isURLEditable = descriptorType === DESCRIPTOR_TYPES.TAB; + + return dom.span( + { + key: url, + className: "debug-target-url", + }, + isURLEditable + ? this.renderTargetInput(url) + : dom.span( + { className: "debug-target-url-readonly devtools-ellipsis-text" }, + url + ) + ); + } + + renderTargetInput(url) { + return dom.form( + { + className: "debug-target-url-form", + onSubmit: this.onSubmit, + }, + dom.input({ + className: "devtools-textinput debug-target-url-input", + onChange: this.onChange, + onFocus: this.onFocus, + defaultValue: url, + }) + ); + } + + renderAlwaysOnTopButton() { + // This is only displayed for local web extension debugging + const { descriptorType, connectionType } = this.props.debugTargetData; + const isLocalWebExtension = + descriptorType === DESCRIPTOR_TYPES.EXTENSION && + connectionType === CONNECTION_TYPES.THIS_FIREFOX; + if (!isLocalWebExtension) { + return []; + } + + const checked = this.props.alwaysOnTop; + const toolboxFocused = this.props.focusedState; + return [ + Localized( + { + id: checked + ? "toolbox-always-on-top-enabled2" + : "toolbox-always-on-top-disabled2", + attrs: { title: true }, + }, + dom.button({ + className: + `toolbox-always-on-top` + + (checked ? " checked" : "") + + (toolboxFocused ? " toolbox-is-focused" : ""), + onClick: this.props.toggleAlwaysOnTop, + }) + ), + ]; + } + + renderNavigationButton(detail) { + const { L10N } = this.props; + + return dom.button( + { + className: `iconized-label navigation-button ${detail.className}`, + onClick: detail.onClick, + title: L10N.getStr(detail.l10nId), + }, + dom.img({ + src: detail.icon, + alt: L10N.getStr(detail.l10nId), + }) + ); + } + + renderNavigation() { + const { debugTargetData } = this.props; + const { descriptorType } = debugTargetData; + + if ( + descriptorType !== DESCRIPTOR_TYPES.TAB && + descriptorType !== DESCRIPTOR_TYPES.EXTENSION + ) { + return null; + } + + const items = []; + + // There is little value in exposing back/forward for WebExtensions + if ( + this.props.toolbox.target.getTrait("navigation") && + descriptorType === DESCRIPTOR_TYPES.TAB + ) { + items.push( + this.renderNavigationButton({ + className: "qa-back-button", + icon: "chrome://browser/skin/back.svg", + l10nId: "toolbox.debugTargetInfo.back", + onClick: () => this.props.toolbox.target.goBack(), + }), + this.renderNavigationButton({ + className: "qa-forward-button", + icon: "chrome://browser/skin/forward.svg", + l10nId: "toolbox.debugTargetInfo.forward", + onClick: () => this.props.toolbox.target.goForward(), + }) + ); + } + + items.push( + this.renderNavigationButton({ + className: "qa-reload-button", + icon: "chrome://global/skin/icons/reload.svg", + l10nId: "toolbox.debugTargetInfo.reload", + onClick: () => + this.props.toolbox.commands.targetCommand.reloadTopLevelTarget(), + }) + ); + + return dom.div( + { + className: "debug-target-navigation", + }, + ...items + ); + } + + render() { + return dom.header( + { + className: "debug-target-info qa-debug-target-info", + }, + this.shallRenderConnection() ? this.renderConnection() : null, + this.renderRuntime(), + this.renderTargetTitle(), + this.renderNavigation(), + this.renderTargetURI(), + ...this.renderAlwaysOnTopButton() + ); + } +} + +module.exports = DebugTargetInfo; diff --git a/devtools/client/framework/components/MeatballMenu.js b/devtools/client/framework/components/MeatballMenu.js new file mode 100644 index 0000000000..fc694171c8 --- /dev/null +++ b/devtools/client/framework/components/MeatballMenu.js @@ -0,0 +1,299 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { hr } = dom; + +loader.lazyGetter(this, "MenuItem", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuItem.js") + ); +}); +loader.lazyGetter(this, "MenuList", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuList.js") + ); +}); + +loader.lazyRequireGetter( + this, + "openDocLink", + "resource://devtools/client/shared/link.js", + true +); +loader.lazyRequireGetter( + this, + "assert", + "resource://devtools/shared/DevToolsUtils.js", + true +); + +const openDevToolsDocsLink = () => { + openDocLink("https://firefox-source-docs.mozilla.org/devtools-user/"); +}; + +const openCommunityLink = () => { + openDocLink( + "https://discourse.mozilla.org/c/devtools?utm_source=devtools&utm_medium=tabbar-menu" + ); +}; + +class MeatballMenu extends PureComponent { + static get propTypes() { + return { + // The id of the currently selected tool, e.g. "inspector" + currentToolId: PropTypes.string, + + // List of possible docking options. + hostTypes: PropTypes.arrayOf( + PropTypes.shape({ + position: PropTypes.string.isRequired, + switchHost: PropTypes.func.isRequired, + }) + ), + + // Current docking type. Typically one of the position values in + // |hostTypes| but this is not always the case (e.g. for "browsertoolbox"). + currentHostType: PropTypes.string, + + // Is the split console currently visible? + isSplitConsoleActive: PropTypes.bool, + + // Are we disabling the behavior where pop-ups are automatically closed + // when clicking outside them? + // + // This is a tri-state value that may be true/false or undefined where + // undefined means that the option is not relevant in this context + // (i.e. we're not in a browser toolbox). + disableAutohide: PropTypes.bool, + + // Apply a pseudo-locale to the Firefox UI. This is only available in the browser + // toolbox. This value can be undefined, "accented", "bidi", "none". + pseudoLocale: PropTypes.string, + + // Function to turn the options panel on / off. + toggleOptions: PropTypes.func.isRequired, + + // Function to turn the split console on / off. + toggleSplitConsole: PropTypes.func, + + // Function to turn the disable pop-up autohide behavior on / off. + toggleNoAutohide: PropTypes.func, + + // Manage the pseudo-localization for the Firefox UI. + // https://firefox-source-docs.mozilla.org/l10n/fluent/tutorial.html#manually-testing-ui-with-pseudolocalization + disablePseudoLocale: PropTypes.func, + enableAccentedPseudoLocale: PropTypes.func, + enableBidiPseudoLocale: PropTypes.func, + + // Bug 1709191 - The help shortcut key is localized without Fluent, and still needs + // to be migrated. This is the only remaining use of the legacy L10N object. + // Everything else should prefer the Fluent API. + L10N: PropTypes.object.isRequired, + + // Callback function that will be invoked any time the component contents + // update in such a way that its bounding box might change. + onResize: PropTypes.func, + }; + } + + componentDidUpdate(prevProps) { + if (!this.props.onResize) { + return; + } + + // We are only expecting the following kinds of dynamic changes when a popup + // is showing: + // + // - The "Disable pop-up autohide" menu item being added after the Browser + // Toolbox is connected. + // - The pseudo locale options being added after the Browser Toolbox is connected. + // - The split console label changing between "Show Split Console" and "Hide + // Split Console". + // - The "Show/Hide Split Console" entry being added removed or removed. + // + // The latter two cases are only likely to be noticed when "Disable pop-up + // autohide" is active, but for completeness we handle them here. + const didChange = + typeof this.props.disableAutohide !== typeof prevProps.disableAutohide || + this.props.pseudoLocale !== prevProps.pseudoLocale || + this.props.currentToolId !== prevProps.currentToolId || + this.props.isSplitConsoleActive !== prevProps.isSplitConsoleActive; + + if (didChange) { + this.props.onResize(); + } + } + + render() { + const items = []; + + // Dock options + for (const hostType of this.props.hostTypes) { + // This is more verbose than it needs to be but lets us easily search for + // l10n entities. + let l10nID; + switch (hostType.position) { + case "window": + l10nID = "toolbox-meatball-menu-dock-separate-window-label"; + break; + + case "bottom": + l10nID = "toolbox-meatball-menu-dock-bottom-label"; + break; + + case "left": + l10nID = "toolbox-meatball-menu-dock-left-label"; + break; + + case "right": + l10nID = "toolbox-meatball-menu-dock-right-label"; + break; + + default: + assert(false, `Unexpected hostType.position: ${hostType.position}`); + break; + } + + items.push( + MenuItem({ + id: `toolbox-meatball-menu-dock-${hostType.position}`, + key: `dock-${hostType.position}`, + l10nID, + onClick: hostType.switchHost, + checked: hostType.position === this.props.currentHostType, + className: "iconic", + }) + ); + } + + if (items.length) { + items.push(hr({ key: "dock-separator" })); + } + + // Split console + if (this.props.currentToolId !== "webconsole") { + const l10nID = this.props.isSplitConsoleActive + ? "toolbox-meatball-menu-hideconsole-label" + : "toolbox-meatball-menu-splitconsole-label"; + items.push( + MenuItem({ + id: "toolbox-meatball-menu-splitconsole", + key: "splitconsole", + l10nID, + accelerator: "Esc", + onClick: this.props.toggleSplitConsole, + className: "iconic", + }) + ); + } + + // Settings + items.push( + MenuItem({ + id: "toolbox-meatball-menu-settings", + key: "settings", + l10nID: "toolbox-meatball-menu-settings-label", + // Bug 1709191 - The help key is localized without Fluent, and still needs to + // be migrated. + accelerator: this.props.L10N.getStr("toolbox.help.key"), + onClick: this.props.toggleOptions, + className: "iconic", + }) + ); + + if ( + typeof this.props.disableAutohide !== "undefined" || + typeof this.props.pseudoLocale !== "undefined" + ) { + items.push(hr({ key: "docs-separator-1" })); + } + + // Disable pop-up autohide + // + // If |disableAutohide| is undefined, it means this feature is not available + // in this context. + if (typeof this.props.disableAutohide !== "undefined") { + items.push( + MenuItem({ + id: "toolbox-meatball-menu-noautohide", + key: "noautohide", + l10nID: "toolbox-meatball-menu-noautohide-label", + type: "checkbox", + checked: this.props.disableAutohide, + onClick: this.props.toggleNoAutohide, + className: "iconic", + }) + ); + } + + // Pseudo-locales. + if (typeof this.props.pseudoLocale !== "undefined") { + const { + pseudoLocale, + enableAccentedPseudoLocale, + enableBidiPseudoLocale, + disablePseudoLocale, + } = this.props; + items.push( + MenuItem({ + id: "toolbox-meatball-menu-pseudo-locale-accented", + key: "pseudo-locale-accented", + l10nID: "toolbox-meatball-menu-pseudo-locale-accented", + type: "checkbox", + checked: pseudoLocale === "accented", + onClick: + pseudoLocale === "accented" + ? disablePseudoLocale + : enableAccentedPseudoLocale, + className: "iconic", + }), + MenuItem({ + id: "toolbox-meatball-menu-pseudo-locale-bidi", + key: "pseudo-locale-bidi", + l10nID: "toolbox-meatball-menu-pseudo-locale-bidi", + type: "checkbox", + checked: pseudoLocale === "bidi", + onClick: + pseudoLocale === "bidi" + ? disablePseudoLocale + : enableBidiPseudoLocale, + className: "iconic", + }) + ); + } + + items.push(hr({ key: "docs-separator-2" })); + + // Getting started + items.push( + MenuItem({ + id: "toolbox-meatball-menu-documentation", + key: "documentation", + l10nID: "toolbox-meatball-menu-documentation-label", + onClick: openDevToolsDocsLink, + }) + ); + + // Give feedback + items.push( + MenuItem({ + id: "toolbox-meatball-menu-community", + key: "community", + l10nID: "toolbox-meatball-menu-community-label", + onClick: openCommunityLink, + }) + ); + + return MenuList({ id: "toolbox-meatball-menu" }, items); + } +} + +module.exports = MeatballMenu; diff --git a/devtools/client/framework/components/ToolboxController.js b/devtools/client/framework/components/ToolboxController.js new file mode 100644 index 0000000000..17d0c8a278 --- /dev/null +++ b/devtools/client/framework/components/ToolboxController.js @@ -0,0 +1,231 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const ToolboxToolbar = createFactory( + require("resource://devtools/client/framework/components/ToolboxToolbar.js") +); +const ELEMENT_PICKER_ID = "command-button-pick"; + +/** + * This component serves as a state controller for the toolbox React component. It's a + * thin layer for translating events and state of the outside world into the React update + * cycle. This solution was used to keep the amount of code changes to a minimimum while + * adapting the existing codebase to start using React. + */ +class ToolboxController extends Component { + constructor(props, context) { + super(props, context); + + // See the ToolboxToolbar propTypes for documentation on each of these items in + // state, and for the definitions of the props that are expected to be passed in. + this.state = { + focusedButton: ELEMENT_PICKER_ID, + toolboxButtons: [], + visibleToolboxButtonCount: 0, + currentToolId: null, + highlightedTools: new Set(), + panelDefinitions: [], + hostTypes: [], + currentHostType: undefined, + areDockOptionsEnabled: true, + canCloseToolbox: true, + isSplitConsoleActive: false, + disableAutohide: undefined, + alwaysOnTop: undefined, + pseudoLocale: undefined, + canRender: false, + buttonIds: [], + checkedButtonsUpdated: () => { + this.forceUpdate(); + }, + }; + + this.setFocusedButton = this.setFocusedButton.bind(this); + this.setToolboxButtons = this.setToolboxButtons.bind(this); + this.setCurrentToolId = this.setCurrentToolId.bind(this); + this.highlightTool = this.highlightTool.bind(this); + this.unhighlightTool = this.unhighlightTool.bind(this); + this.setHostTypes = this.setHostTypes.bind(this); + this.setCurrentHostType = this.setCurrentHostType.bind(this); + this.setDockOptionsEnabled = this.setDockOptionsEnabled.bind(this); + this.setCanCloseToolbox = this.setCanCloseToolbox.bind(this); + this.setIsSplitConsoleActive = this.setIsSplitConsoleActive.bind(this); + this.setDisableAutohide = this.setDisableAutohide.bind(this); + this.setCanRender = this.setCanRender.bind(this); + this.setPanelDefinitions = this.setPanelDefinitions.bind(this); + this.updateButtonIds = this.updateButtonIds.bind(this); + this.updateFocusedButton = this.updateFocusedButton.bind(this); + this.setDebugTargetData = this.setDebugTargetData.bind(this); + } + + shouldComponentUpdate() { + return this.state.canRender; + } + + componentWillUnmount() { + this.state.toolboxButtons.forEach(button => { + button.off("updatechecked", this.state.checkedButtonsUpdated); + }); + } + + /** + * The button and tab ids must be known in order to be able to focus left and right + * using the arrow keys. + */ + updateButtonIds() { + const { toolboxButtons, panelDefinitions, canCloseToolbox } = this.state; + + // This is a little gnarly, but go through all of the state and extract the IDs. + this.setState({ + buttonIds: [ + ...toolboxButtons + .filter(btn => btn.isInStartContainer) + .map(({ id }) => id), + ...panelDefinitions.map(({ id }) => id), + ...toolboxButtons + .filter(btn => !btn.isInStartContainer) + .map(({ id }) => id), + canCloseToolbox ? "toolbox-close" : null, + ].filter(id => id), + }); + + this.updateFocusedButton(); + } + + updateFocusedButton() { + this.setFocusedButton(this.state.focusedButton); + } + + setFocusedButton(focusedButton) { + const { buttonIds } = this.state; + + focusedButton = + focusedButton && buttonIds.includes(focusedButton) + ? focusedButton + : buttonIds[0]; + if (this.state.focusedButton !== focusedButton) { + this.setState({ + focusedButton, + }); + } + } + + setCurrentToolId(currentToolId) { + this.setState({ currentToolId }, () => { + // Also set the currently focused button to this tool. + this.setFocusedButton(currentToolId); + }); + } + + setCanRender() { + this.setState({ canRender: true }, this.updateButtonIds); + } + + highlightTool(highlightedTool) { + const { highlightedTools } = this.state; + highlightedTools.add(highlightedTool); + this.setState({ highlightedTools }); + } + + unhighlightTool(id) { + const { highlightedTools } = this.state; + if (highlightedTools.has(id)) { + highlightedTools.delete(id); + this.setState({ highlightedTools }); + } + } + + setDockOptionsEnabled(areDockOptionsEnabled) { + this.setState({ areDockOptionsEnabled }); + } + + setHostTypes(hostTypes) { + this.setState({ hostTypes }); + } + + setCurrentHostType(currentHostType) { + this.setState({ currentHostType }); + } + + setCanCloseToolbox(canCloseToolbox) { + this.setState({ canCloseToolbox }, this.updateButtonIds); + } + + setIsSplitConsoleActive(isSplitConsoleActive) { + this.setState({ isSplitConsoleActive }); + } + + /** + * @param {bool | undefined} disableAutohide + */ + setDisableAutohide(disableAutohide) { + this.setState({ disableAutohide }); + } + + /** + * @param {bool | undefined} alwaysOnTop + */ + setAlwaysOnTop(alwaysOnTop) { + this.setState({ alwaysOnTop }); + } + + /** + * @param {bool} focusedState + */ + setFocusedState(focusedState) { + // We only care about the focused state when the toolbox is always on top + if (this.state.alwaysOnTop) { + this.setState({ focusedState }); + } + } + + /** + * @param {"bidi" | "accented" | "none" | undefined} pseudoLocale + */ + setPseudoLocale(pseudoLocale) { + this.setState({ pseudoLocale }); + } + + setPanelDefinitions(panelDefinitions) { + this.setState({ panelDefinitions }, this.updateButtonIds); + } + + get panelDefinitions() { + return this.state.panelDefinitions; + } + + setToolboxButtons(toolboxButtons) { + // Listen for updates of the checked attribute. + this.state.toolboxButtons.forEach(button => { + button.off("updatechecked", this.state.checkedButtonsUpdated); + }); + toolboxButtons.forEach(button => { + button.on("updatechecked", this.state.checkedButtonsUpdated); + }); + + const visibleToolboxButtonCount = toolboxButtons.filter( + button => button.isVisible + ).length; + + this.setState( + { toolboxButtons, visibleToolboxButtonCount }, + this.updateButtonIds + ); + } + + setDebugTargetData(data) { + this.setState({ debugTargetData: data }); + } + + render() { + return ToolboxToolbar(Object.assign({}, this.props, this.state)); + } +} + +module.exports = ToolboxController; diff --git a/devtools/client/framework/components/ToolboxTab.js b/devtools/client/framework/components/ToolboxTab.js new file mode 100644 index 0000000000..680b68e3e5 --- /dev/null +++ b/devtools/client/framework/components/ToolboxTab.js @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { img, button, span } = dom; + +class ToolboxTab extends Component { + // See toolbox-toolbar propTypes for details on the props used here. + static get propTypes() { + return { + currentToolId: PropTypes.string, + focusButton: PropTypes.func, + focusedButton: PropTypes.string, + highlightedTools: PropTypes.object.isRequired, + panelDefinition: PropTypes.object, + selectTool: PropTypes.func, + }; + } + + constructor(props) { + super(props); + this.renderIcon = this.renderIcon.bind(this); + } + + renderIcon(definition) { + const { icon } = definition; + if (!icon) { + return []; + } + return [ + img({ + alt: "", + src: icon, + }), + ]; + } + + render() { + const { + panelDefinition, + currentToolId, + highlightedTools, + selectTool, + focusedButton, + focusButton, + } = this.props; + const { id, extensionId, tooltip, label, iconOnly, badge } = + panelDefinition; + const isHighlighted = id === currentToolId; + + const className = [ + "devtools-tab", + currentToolId === id ? "selected" : "", + highlightedTools.has(id) ? "highlighted" : "", + iconOnly ? "devtools-tab-icon-only" : "", + ].join(" "); + + return button( + { + className, + id: `toolbox-tab-${id}`, + "data-id": id, + "data-extension-id": extensionId, + title: tooltip, + type: "button", + "aria-pressed": currentToolId === id ? "true" : "false", + tabIndex: focusedButton === id ? "0" : "-1", + onFocus: () => focusButton(id), + onMouseDown: () => selectTool(id, "tab_switch"), + onKeyDown: evt => { + if (evt.key === "Enter" || evt.key === " ") { + selectTool(id, "tab_switch"); + } + }, + }, + span({ + className: "devtools-tab-line", + }), + ...this.renderIcon(panelDefinition), + iconOnly + ? null + : span( + { + className: "devtools-tab-label", + }, + label, + badge && !isHighlighted + ? span( + { + className: "devtools-tab-badge", + }, + badge + ) + : null + ) + ); + } +} + +module.exports = ToolboxTab; diff --git a/devtools/client/framework/components/ToolboxTabs.js b/devtools/client/framework/components/ToolboxTabs.js new file mode 100644 index 0000000000..04b7d653a4 --- /dev/null +++ b/devtools/client/framework/components/ToolboxTabs.js @@ -0,0 +1,331 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + Component, + createFactory, + createRef, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + ToolboxTabsOrderManager, +} = require("resource://devtools/client/framework/toolbox-tabs-order-manager.js"); + +const { div } = dom; + +const ToolboxTab = createFactory( + require("resource://devtools/client/framework/components/ToolboxTab.js") +); + +loader.lazyGetter(this, "MenuButton", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuButton.js") + ); +}); +loader.lazyGetter(this, "MenuItem", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuItem.js") + ); +}); +loader.lazyGetter(this, "MenuList", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuList.js") + ); +}); + +// 26px is chevron devtools button width.(i.e. tools-chevronmenu) +const CHEVRON_BUTTON_WIDTH = 26; + +class ToolboxTabs extends Component { + // See toolbox-toolbar propTypes for details on the props used here. + static get propTypes() { + return { + currentToolId: PropTypes.string, + focusButton: PropTypes.func, + focusedButton: PropTypes.string, + highlightedTools: PropTypes.object, + panelDefinitions: PropTypes.array, + selectTool: PropTypes.func, + toolbox: PropTypes.object, + visibleToolboxButtonCount: PropTypes.number.isRequired, + L10N: PropTypes.object, + onTabsOrderUpdated: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + // Array of overflowed tool id. + overflowedTabIds: [], + }; + + this.wrapperEl = createRef(); + + // Map with tool Id and its width size. This lifecycle is out of React's + // lifecycle. If a tool is registered, ToolboxTabs will add target tool id + // to this map. ToolboxTabs will never remove tool id from this cache. + this._cachedToolTabsWidthMap = new Map(); + + this._resizeTimerId = null; + this.resizeHandler = this.resizeHandler.bind(this); + + const { toolbox, onTabsOrderUpdated, panelDefinitions } = props; + this._tabsOrderManager = new ToolboxTabsOrderManager( + toolbox, + onTabsOrderUpdated, + panelDefinitions + ); + } + + componentDidMount() { + window.addEventListener("resize", this.resizeHandler); + this.updateCachedToolTabsWidthMap(); + this.updateOverflowedTabs(); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate(nextProps, nextState) { + if (this.shouldUpdateToolboxTabs(this.props, nextProps)) { + // Force recalculate and render in this cycle if panel definition has + // changed or selected tool has changed. + nextState.overflowedTabIds = []; + } + } + + componentDidUpdate(prevProps, prevState) { + if (this.shouldUpdateToolboxTabs(prevProps, this.props)) { + this.updateCachedToolTabsWidthMap(); + this.updateOverflowedTabs(); + this._tabsOrderManager.setCurrentPanelDefinitions( + this.props.panelDefinitions + ); + } + } + + componentWillUnmount() { + window.removeEventListener("resize", this.resizeHandler); + window.cancelIdleCallback(this._resizeTimerId); + this._tabsOrderManager.destroy(); + } + + /** + * Check if two array of ids are the same or not. + */ + equalToolIdArray(prevPanels, nextPanels) { + if (prevPanels.length !== nextPanels.length) { + return false; + } + + // Check panel definitions even if both of array size is same. + // For example, the case of changing the tab's order. + return prevPanels.join("-") === nextPanels.join("-"); + } + + /** + * Return true if we should update the overflowed tabs. + */ + shouldUpdateToolboxTabs(prevProps, nextProps) { + if ( + prevProps.currentToolId !== nextProps.currentToolId || + prevProps.visibleToolboxButtonCount !== + nextProps.visibleToolboxButtonCount + ) { + return true; + } + + const prevPanels = prevProps.panelDefinitions.map(def => def.id); + const nextPanels = nextProps.panelDefinitions.map(def => def.id); + return !this.equalToolIdArray(prevPanels, nextPanels); + } + + /** + * Update the Map of tool id and tool tab width. + */ + updateCachedToolTabsWidthMap() { + const utils = window.windowUtils; + // Force a reflow before calling getBoundingWithoutFlushing on each tab. + this.wrapperEl.current.clientWidth; + + for (const tab of this.wrapperEl.current.querySelectorAll( + ".devtools-tab" + )) { + const tabId = tab.id.replace("toolbox-tab-", ""); + if (!this._cachedToolTabsWidthMap.has(tabId)) { + const rect = utils.getBoundsWithoutFlushing(tab); + this._cachedToolTabsWidthMap.set(tabId, rect.width); + } + } + } + + /** + * Update the overflowed tab array from currently displayed tool tab. + * If calculated result is the same as the current overflowed tab array, this + * function will not update state. + */ + updateOverflowedTabs() { + const toolboxWidth = parseInt( + getComputedStyle(this.wrapperEl.current).width, + 10 + ); + const { currentToolId } = this.props; + const enabledTabs = this.props.panelDefinitions.map(def => def.id); + let sumWidth = 0; + const visibleTabs = []; + + for (const id of enabledTabs) { + const width = this._cachedToolTabsWidthMap.get(id); + sumWidth += width; + if (sumWidth <= toolboxWidth) { + visibleTabs.push(id); + } else { + sumWidth = sumWidth - width + CHEVRON_BUTTON_WIDTH; + + // If toolbox can't display the Chevron, remove the last tool tab. + if (sumWidth > toolboxWidth) { + const removeTabId = visibleTabs.pop(); + sumWidth -= this._cachedToolTabsWidthMap.get(removeTabId); + } + break; + } + } + + // If the selected tab is in overflowed tabs, insert it into a visible + // toolbox. + if ( + !visibleTabs.includes(currentToolId) && + enabledTabs.includes(currentToolId) + ) { + const selectedToolWidth = this._cachedToolTabsWidthMap.get(currentToolId); + while ( + sumWidth + selectedToolWidth > toolboxWidth && + visibleTabs.length + ) { + const removingToolId = visibleTabs.pop(); + const removingToolWidth = + this._cachedToolTabsWidthMap.get(removingToolId); + sumWidth -= removingToolWidth; + } + + // If toolbox width is narrow, toolbox display only chevron menu. + // i.e. All tool tabs will overflow. + if (sumWidth + selectedToolWidth <= toolboxWidth) { + visibleTabs.push(currentToolId); + } + } + + const willOverflowTabs = enabledTabs.filter( + id => !visibleTabs.includes(id) + ); + if (!this.equalToolIdArray(this.state.overflowedTabIds, willOverflowTabs)) { + this.setState({ overflowedTabIds: willOverflowTabs }); + } + } + + resizeHandler(evt) { + window.cancelIdleCallback(this._resizeTimerId); + this._resizeTimerId = window.requestIdleCallback( + () => { + this.updateOverflowedTabs(); + }, + { timeout: 100 } + ); + } + + renderToolsChevronMenuList() { + const { panelDefinitions, selectTool } = this.props; + + const items = []; + for (const { id, label, icon } of panelDefinitions) { + if (this.state.overflowedTabIds.includes(id)) { + items.push( + MenuItem({ + key: id, + id: "tools-chevron-menupopup-" + id, + label, + type: "checkbox", + onClick: () => { + selectTool(id, "tab_switch"); + }, + icon, + }) + ); + } + } + + return MenuList({ id: "tools-chevron-menupopup" }, items); + } + + /** + * Render a button to access overflowed tools, displayed only when the toolbar + * presents an overflow. + */ + renderToolsChevronButton() { + const { toolbox } = this.props; + + return MenuButton( + { + id: "tools-chevron-menu-button", + menuId: "tools-chevron-menu-button-panel", + className: "devtools-tabbar-button tools-chevron-menu", + toolboxDoc: toolbox.doc, + }, + this.renderToolsChevronMenuList() + ); + } + + /** + * Render all of the tabs, based on the panel definitions and builds out + * a toolbox tab for each of them. Will render the chevron button if the + * container has an overflow. + */ + render() { + const { + currentToolId, + focusButton, + focusedButton, + highlightedTools, + panelDefinitions, + selectTool, + } = this.props; + + const tabs = panelDefinitions.map(panelDefinition => { + // Don't display overflowed tab. + if (!this.state.overflowedTabIds.includes(panelDefinition.id)) { + return ToolboxTab({ + key: panelDefinition.id, + currentToolId, + focusButton, + focusedButton, + highlightedTools, + panelDefinition, + selectTool, + }); + } + return null; + }); + + return div( + { + className: "toolbox-tabs-wrapper", + ref: this.wrapperEl, + }, + div( + { + className: "toolbox-tabs", + onMouseDown: e => this._tabsOrderManager.onMouseDown(e), + }, + tabs, + this.state.overflowedTabIds.length + ? this.renderToolsChevronButton() + : null + ) + ); + } +} + +module.exports = ToolboxTabs; diff --git a/devtools/client/framework/components/ToolboxToolbar.js b/devtools/client/framework/components/ToolboxToolbar.js new file mode 100644 index 0000000000..6f94d0282b --- /dev/null +++ b/devtools/client/framework/components/ToolboxToolbar.js @@ -0,0 +1,547 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { div, button } = dom; +const MenuButton = createFactory( + require("resource://devtools/client/shared/components/menu/MenuButton.js") +); +const ToolboxTabs = createFactory( + require("resource://devtools/client/framework/components/ToolboxTabs.js") +); +loader.lazyGetter(this, "MeatballMenu", function () { + return createFactory( + require("resource://devtools/client/framework/components/MeatballMenu.js") + ); +}); +loader.lazyGetter(this, "MenuItem", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuItem.js") + ); +}); +loader.lazyGetter(this, "MenuList", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuList.js") + ); +}); +loader.lazyGetter(this, "LocalizationProvider", function () { + return createFactory( + require("resource://devtools/client/shared/vendor/fluent-react.js") + .LocalizationProvider + ); +}); +loader.lazyGetter(this, "DebugTargetInfo", () => + createFactory( + require("resource://devtools/client/framework/components/DebugTargetInfo.js") + ) +); +loader.lazyGetter(this, "ChromeDebugToolbar", () => + createFactory( + require("resource://devtools/client/framework/components/ChromeDebugToolbar.js") + ) +); + +loader.lazyRequireGetter( + this, + "getUnicodeUrl", + "resource://devtools/client/shared/unicode-url.js", + true +); + +/** + * This is the overall component for the toolbox toolbar. It is designed to not know how + * the state is being managed, and attempts to be as pure as possible. The + * ToolboxController component controls the changing state, and passes in everything as + * props. + */ +class ToolboxToolbar extends Component { + static get propTypes() { + return { + // The currently focused item (for arrow keyboard navigation) + // This ID determines the tabindex being 0 or -1. + focusedButton: PropTypes.string, + // List of command button definitions. + toolboxButtons: PropTypes.array, + // The id of the currently selected tool, e.g. "inspector" + currentToolId: PropTypes.string, + // An optionally highlighted tools, e.g. "inspector" (used by ToolboxTabs + // component). + highlightedTools: PropTypes.instanceOf(Set), + // List of tool panel definitions (used by ToolboxTabs component). + panelDefinitions: PropTypes.array, + // List of possible docking options. + hostTypes: PropTypes.arrayOf( + PropTypes.shape({ + position: PropTypes.string.isRequired, + switchHost: PropTypes.func.isRequired, + }) + ), + // Current docking type. Typically one of the position values in + // |hostTypes| but this is not always the case (e.g. for "browsertoolbox"). + currentHostType: PropTypes.string, + // Are docking options enabled? They are not enabled in certain situations + // like when the toolbox is opened in a tab. + areDockOptionsEnabled: PropTypes.bool, + // Do we need to add UI for closing the toolbox? We don't when the + // toolbox is undocked, for example. + canCloseToolbox: PropTypes.bool, + // Is the split console currently visible? + isSplitConsoleActive: PropTypes.bool, + // Are we disabling the behavior where pop-ups are automatically closed + // when clicking outside them? + // + // This is a tri-state value that may be true/false or undefined where + // undefined means that the option is not relevant in this context + // (i.e. we're not in a browser toolbox). + disableAutohide: PropTypes.bool, + // Are we displaying the window always on top? + // + // This is a tri-state value that may be true/false or undefined where + // undefined means that the option is not relevant in this context + // (i.e. we're not in a local web extension toolbox). + alwaysOnTop: PropTypes.bool, + // Is the toolbox currently focused? + // + // This will only be defined when alwaysOnTop is true. + focusedState: PropTypes.bool, + // Function to turn the options panel on / off. + toggleOptions: PropTypes.func.isRequired, + // Function to turn the split console on / off. + toggleSplitConsole: PropTypes.func, + // Function to turn the disable pop-up autohide behavior on / off. + toggleNoAutohide: PropTypes.func, + // Function to turn the always on top behavior on / off. + toggleAlwaysOnTop: PropTypes.func, + // Function to completely close the toolbox. + closeToolbox: PropTypes.func, + // Keep a record of what button is focused. + focusButton: PropTypes.func, + // Hold off displaying the toolbar until enough information is ready for + // it to render nicely. + canRender: PropTypes.bool, + // Localization interface. + L10N: PropTypes.object.isRequired, + // The devtools toolbox + toolbox: PropTypes.object, + // Call back function to detect tabs order updated. + onTabsOrderUpdated: PropTypes.func.isRequired, + // Count of visible toolbox buttons which is used by ToolboxTabs component + // to recognize that the visibility of toolbox buttons were changed. + // Because in the component we cannot compare the visibility since the + // button definition instance in toolboxButtons will be unchanged. + visibleToolboxButtonCount: PropTypes.number, + // Data to show debug target info, if needed + debugTargetData: PropTypes.shape({ + runtimeInfo: PropTypes.object.isRequired, + descriptorType: PropTypes.string.isRequired, + }), + // The loaded Fluent localization bundles. + fluentBundles: PropTypes.array.isRequired, + }; + } + + constructor(props) { + super(props); + + this.hideMenu = this.hideMenu.bind(this); + this.createFrameList = this.createFrameList.bind(this); + this.highlightFrame = this.highlightFrame.bind(this); + } + + componentDidMount() { + this.props.toolbox.on("panel-changed", this.hideMenu); + } + + componentWillUnmount() { + this.props.toolbox.off("panel-changed", this.hideMenu); + } + + hideMenu() { + if (this.refs.meatballMenuButton) { + this.refs.meatballMenuButton.hideMenu(); + } + + if (this.refs.frameMenuButton) { + this.refs.frameMenuButton.hideMenu(); + } + } + + /** + * A little helper function to call renderToolboxButtons for buttons at the start + * of the toolbox. + */ + renderToolboxButtonsStart() { + return this.renderToolboxButtons(true); + } + + /** + * A little helper function to call renderToolboxButtons for buttons at the end + * of the toolbox. + */ + renderToolboxButtonsEnd() { + return this.renderToolboxButtons(false); + } + + /** + * Render all of the tabs, this takes in a list of toolbox button states. These are plain + * objects that have all of the relevant information needed to render the button. + * See Toolbox.prototype._createButtonState in devtools/client/framework/toolbox.js for + * documentation on this object. + * + * @param {String} focusedButton - The id of the focused button. + * @param {Array} toolboxButtons - Array of objects that define the command buttons. + * @param {Function} focusButton - Keep a record of the currently focused button. + * @param {boolean} isStart - Render either the starting buttons, or ending buttons. + */ + renderToolboxButtons(isStart) { + const { focusedButton, toolboxButtons, focusButton } = this.props; + const visibleButtons = toolboxButtons.filter(command => { + const { isVisible, isInStartContainer } = command; + return isVisible && (isStart ? isInStartContainer : !isInStartContainer); + }); + + if (visibleButtons.length === 0) { + return null; + } + + // The RDM button, if present, should always go last + const rdmIndex = visibleButtons.findIndex( + button => button.id === "command-button-responsive" + ); + if (rdmIndex !== -1 && rdmIndex !== visibleButtons.length - 1) { + const rdm = visibleButtons.splice(rdmIndex, 1)[0]; + visibleButtons.push(rdm); + } + + const renderedButtons = visibleButtons.map(command => { + const { + id, + description, + disabled, + onClick, + isChecked, + isToggle, + className: buttonClass, + onKeyDown, + } = command; + + // If button is frame button, create menu button in order to + // use the doorhanger menu. + if (id === "command-button-frames") { + return this.renderFrameButton(command); + } + + if (id === "command-button-errorcount") { + return this.renderErrorIcon(command); + } + + return button({ + id, + title: description, + disabled, + "aria-pressed": !isToggle ? null : isChecked, + className: `devtools-tabbar-button command-button ${ + buttonClass || "" + } ${isChecked ? "checked" : ""}`, + onClick: event => { + onClick(event); + focusButton(id); + }, + onFocus: () => focusButton(id), + tabIndex: id === focusedButton ? "0" : "-1", + onKeyDown: event => { + onKeyDown(event); + }, + }); + }); + + // Add the appropriate separator, if needed. + const children = renderedButtons; + if (renderedButtons.length) { + if (isStart) { + children.push(this.renderSeparator()); + // For the end group we add a separator *before* the RDM button if it + // exists, but only if it is not the only button. + } else if (rdmIndex !== -1 && renderedButtons.length > 1) { + children.splice(children.length - 1, 0, this.renderSeparator()); + } + } + + return div( + { id: `toolbox-buttons-${isStart ? "start" : "end"}` }, + ...children + ); + } + + renderFrameButton(command) { + const { id, isChecked, disabled, description } = command; + + const { toolbox } = this.props; + + return MenuButton( + { + id, + disabled, + menuId: id + "-panel", + toolboxDoc: toolbox.doc, + className: `devtools-tabbar-button command-button ${ + isChecked ? "checked" : "" + }`, + ref: "frameMenuButton", + title: description, + onCloseButton: async () => { + // Only try to unhighlight if the inspectorFront has been created already + const inspectorFront = toolbox.target.getCachedFront("inspector"); + if (inspectorFront) { + const highlighter = toolbox.getHighlighter(); + await highlighter.unhighlight(); + } + }, + }, + this.createFrameList + ); + } + + renderErrorIcon(command) { + let { errorCount, id } = command; + + if (!errorCount) { + return null; + } + + if (errorCount > 99) { + errorCount = "99+"; + } + + return button( + { + id, + className: "devtools-tabbar-button command-button toolbox-error", + onClick: () => { + if (this.props.currentToolId !== "webconsole") { + this.props.toolbox.openSplitConsole(); + } + }, + title: + this.props.currentToolId !== "webconsole" + ? this.props.L10N.getStr("toolbox.errorCountButton.tooltip") + : null, + }, + errorCount + ); + } + + highlightFrame(id) { + const { toolbox } = this.props; + if (!id) { + return; + } + + toolbox.onHighlightFrame(id); + } + + createFrameList() { + const { toolbox } = this.props; + if (toolbox.frameMap.size < 1) { + return null; + } + + const items = []; + toolbox.frameMap.forEach((frame, index) => { + const label = toolbox.target.isWebExtension + ? toolbox.target.getExtensionPathName(frame.url) + : getUnicodeUrl(frame.url); + + const item = MenuItem({ + id: frame.id.toString(), + key: "toolbox-frame-key-" + frame.id, + label, + checked: frame.id === toolbox.selectedFrameId, + onClick: () => toolbox.onIframePickerFrameSelected(frame.id), + }); + + // Always put the top level frame at the top + if (frame.isTopLevel) { + items.unshift(item); + } else { + items.push(item); + } + }); + + return MenuList( + { + id: "toolbox-frame-menu", + onHighlightedChildChange: this.highlightFrame, + }, + items + ); + } + + /** + * Render a separator. + */ + renderSeparator() { + return div({ className: "devtools-separator" }); + } + + /** + * Render the toolbox control buttons. The following props are expected: + * + * @param {string} props.focusedButton + * The id of the focused button. + * @param {string} props.currentToolId + * The id of the currently selected tool, e.g. "inspector". + * @param {Object[]} props.hostTypes + * Array of host type objects. + * @param {string} props.hostTypes[].position + * Position name. + * @param {Function} props.hostTypes[].switchHost + * Function to switch the host. + * @param {string} props.currentHostType + * The current docking configuration. + * @param {boolean} props.areDockOptionsEnabled + * They are not enabled in certain situations like when the toolbox is + * in a tab. + * @param {boolean} props.canCloseToolbox + * Do we need to add UI for closing the toolbox? We don't when the + * toolbox is undocked, for example. + * @param {boolean} props.isSplitConsoleActive + * Is the split console currently visible? + * toolbox is undocked, for example. + * @param {boolean|undefined} props.disableAutohide + * Are we disabling the behavior where pop-ups are automatically + * closed when clicking outside them? + * (Only defined for the browser toolbox.) + * @param {Function} props.selectTool + * Function to select a tool based on its id. + * @param {Function} props.toggleOptions + * Function to turn the options panel on / off. + * @param {Function} props.toggleSplitConsole + * Function to turn the split console on / off. + * @param {Function} props.toggleNoAutohide + * Function to turn the disable pop-up autohide behavior on / off. + * @param {Function} props.toggleAlwaysOnTop + * Function to turn the always on top behavior on / off. + * @param {Function} props.closeToolbox + * Completely close the toolbox. + * @param {Function} props.focusButton + * Keep a record of the currently focused button. + * @param {Object} props.L10N + * Localization interface. + * @param {Object} props.toolbox + * The devtools toolbox. Used by the MenuButton component to display + * the menu popup. + * @param {Object} refs + * The components refs object. Used to keep a reference to the MenuButton + * for the meatball menu so that we can tell it to resize its contents + * when they change. + */ + renderToolboxControls() { + const { + focusedButton, + canCloseToolbox, + closeToolbox, + focusButton, + L10N, + toolbox, + } = this.props; + + const meatballMenuButtonId = "toolbox-meatball-menu-button"; + + const meatballMenuButton = MenuButton( + { + id: meatballMenuButtonId, + menuId: meatballMenuButtonId + "-panel", + toolboxDoc: toolbox.doc, + onFocus: () => focusButton(meatballMenuButtonId), + className: "devtools-tabbar-button", + title: L10N.getStr("toolbox.meatballMenu.button.tooltip"), + tabIndex: focusedButton === meatballMenuButtonId ? "0" : "-1", + ref: "meatballMenuButton", + }, + MeatballMenu({ + ...this.props, + hostTypes: this.props.areDockOptionsEnabled ? this.props.hostTypes : [], + onResize: () => { + this.refs.meatballMenuButton.resizeContent(); + }, + }) + ); + + const closeButtonId = "toolbox-close"; + + const closeButton = canCloseToolbox + ? button({ + id: closeButtonId, + onFocus: () => focusButton(closeButtonId), + className: "devtools-tabbar-button", + title: L10N.getStr("toolbox.closebutton.tooltip"), + onClick: () => closeToolbox(), + tabIndex: focusedButton === "toolbox-close" ? "0" : "-1", + }) + : null; + + return div({ id: "toolbox-controls" }, meatballMenuButton, closeButton); + } + + /** + * The render function is kept fairly short for maintainability. See the individual + * render functions for how each of the sections is rendered. + */ + render() { + const { L10N, debugTargetData, toolbox, fluentBundles } = this.props; + const classnames = ["devtools-tabbar"]; + const startButtons = this.renderToolboxButtonsStart(); + const endButtons = this.renderToolboxButtonsEnd(); + + if (!startButtons) { + classnames.push("devtools-tabbar-has-start"); + } + if (!endButtons) { + classnames.push("devtools-tabbar-has-end"); + } + + const toolbar = this.props.canRender + ? div( + { + className: classnames.join(" "), + }, + startButtons, + ToolboxTabs(this.props), + endButtons, + this.renderToolboxControls() + ) + : div({ className: classnames.join(" ") }); + + const debugTargetInfo = debugTargetData + ? DebugTargetInfo({ + alwaysOnTop: this.props.alwaysOnTop, + focusedState: this.props.focusedState, + toggleAlwaysOnTop: this.props.toggleAlwaysOnTop, + debugTargetData, + L10N, + toolbox, + }) + : null; + + // Display the toolbar in the MBT and about:debugging MBT if we have server support for it. + const chromeDebugToolbar = toolbox.commands.targetCommand.descriptorFront + .isBrowserProcessDescriptor + ? ChromeDebugToolbar() + : null; + + return LocalizationProvider( + { bundles: fluentBundles }, + div({}, chromeDebugToolbar, debugTargetInfo, toolbar) + ); + } +} + +module.exports = ToolboxToolbar; diff --git a/devtools/client/framework/components/moz.build b/devtools/client/framework/components/moz.build new file mode 100644 index 0000000000..cb29f41ddb --- /dev/null +++ b/devtools/client/framework/components/moz.build @@ -0,0 +1,17 @@ +# -*- 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/. + + +DevToolsModules( + "ChromeDebugToolbar.js", + "DebugTargetErrorPage.js", + "DebugTargetInfo.js", + "MeatballMenu.js", + "ToolboxController.js", + "ToolboxTab.js", + "ToolboxTabs.js", + "ToolboxToolbar.js", +) diff --git a/devtools/client/framework/devtools-browser.js b/devtools/client/framework/devtools-browser.js new file mode 100644 index 0000000000..2cfbc09331 --- /dev/null +++ b/devtools/client/framework/devtools-browser.js @@ -0,0 +1,627 @@ +/* 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"; + +/** + * This is the main module loaded in Firefox desktop that handles browser + * windows and coordinates devtools around each window. + * + * This module is loaded lazily by devtools-clhandler.js, once the first + * browser window is ready (i.e. fired browser-delayed-startup-finished event) + **/ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + BrowserToolboxLauncher: + "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs", +}); + +const { + gDevTools, +} = require("resource://devtools/client/framework/devtools.js"); +const { + getTheme, + addThemeObserver, + removeThemeObserver, +} = require("resource://devtools/client/shared/theme.js"); + +// Load toolbox lazily as it needs gDevTools to be fully initialized +loader.lazyRequireGetter( + this, + "Toolbox", + "resource://devtools/client/framework/toolbox.js", + true +); +loader.lazyRequireGetter( + this, + "DevToolsServer", + "resource://devtools/server/devtools-server.js", + true +); +loader.lazyRequireGetter( + this, + "BrowserMenus", + "resource://devtools/client/framework/browser-menus.js" +); +loader.lazyRequireGetter( + this, + "appendStyleSheet", + "resource://devtools/client/shared/stylesheet-utils.js", + true +); +loader.lazyRequireGetter( + this, + "ResponsiveUIManager", + "resource://devtools/client/responsive/manager.js" +); + +const BROWSER_STYLESHEET_URL = "chrome://devtools/skin/devtools-browser.css"; + +const DEVTOOLS_F12_ENABLED_PREF = "devtools.f12_enabled"; + +/** + * gDevToolsBrowser exposes functions to connect the gDevTools instance with a + * Firefox instance. + */ +var gDevToolsBrowser = (exports.gDevToolsBrowser = { + /** + * A record of the windows whose menus we altered, so we can undo the changes + * as the window is closed + */ + _trackedBrowserWindows: new Set(), + + /** + * WeakMap keeping track of the devtools-browser stylesheets loaded in the various + * tracked windows. + */ + _browserStyleSheets: new WeakMap(), + + /** + * This function is for the benefit of Tools:DevToolbox in + * browser/base/content/browser-sets.inc and should not be used outside + * of there + */ + // used by browser-sets.inc, command + toggleToolboxCommand(gBrowser, startTime) { + const toolbox = gDevTools.getToolboxForTab(gBrowser.selectedTab); + + // If a toolbox exists, using toggle from the Main window : + // - should close a docked toolbox + // - should focus a windowed toolbox + const isDocked = toolbox && toolbox.hostType != Toolbox.HostType.WINDOW; + if (isDocked) { + gDevTools.closeToolboxForTab(gBrowser.selectedTab); + } else { + gDevTools.showToolboxForTab(gBrowser.selectedTab, { startTime }); + } + }, + + /** + * This function ensures the right commands are enabled in a window, + * depending on their relevant prefs. It gets run when a window is registered, + * or when any of the devtools prefs change. + */ + updateCommandAvailability(win) { + const doc = win.document; + + function toggleMenuItem(id, isEnabled) { + const cmd = doc.getElementById(id); + cmd.hidden = !isEnabled; + if (isEnabled) { + cmd.removeAttribute("disabled"); + } else { + cmd.setAttribute("disabled", "true"); + } + } + + // Enable Browser Toolbox? + const chromeEnabled = Services.prefs.getBoolPref("devtools.chrome.enabled"); + const devtoolsRemoteEnabled = Services.prefs.getBoolPref( + "devtools.debugger.remote-enabled" + ); + const remoteEnabled = chromeEnabled && devtoolsRemoteEnabled; + toggleMenuItem("menu_browserToolbox", remoteEnabled); + + if (Services.prefs.getBoolPref("devtools.policy.disabled", false)) { + toggleMenuItem("menu_devToolbox", false); + toggleMenuItem("menu_devtools_remotedebugging", false); + toggleMenuItem("menu_browserToolbox", false); + toggleMenuItem("menu_browserConsole", false); + toggleMenuItem("menu_responsiveUI", false); + toggleMenuItem("menu_eyedropper", false); + toggleMenuItem("extensionsForDevelopers", false); + } + }, + + /** + * This function makes sure that the "devtoolstheme" attribute is set on the browser + * window to make it possible to change colors on elements in the browser (like the + * splitter between the toolbox and web content). + */ + updateDevtoolsThemeAttribute(win) { + // Set an attribute on root element of each window to make it possible + // to change colors based on the selected devtools theme. + let devtoolsTheme = getTheme(); + if (devtoolsTheme != "dark") { + devtoolsTheme = "light"; + } + + // Style the splitter between the toolbox and page content. This used to + // set the attribute on the browser's root node but that regressed tpaint: + // bug 1331449. + win.document + .getElementById("appcontent") + .setAttribute("devtoolstheme", devtoolsTheme); + }, + + observe(subject, topic, prefName) { + switch (topic) { + case "browser-delayed-startup-finished": + this._registerBrowserWindow(subject); + break; + case "nsPref:changed": + if (prefName.endsWith("enabled")) { + for (const win of this._trackedBrowserWindows) { + this.updateCommandAvailability(win); + } + } + break; + case "quit-application": + gDevToolsBrowser.destroy({ shuttingDown: true }); + break; + case "devtools:loader:destroy": + // This event is fired when the devtools loader unloads, which happens + // only when the add-on workflow ask devtools to be reloaded. + if (subject.wrappedJSObject == require("@loader/unload")) { + gDevToolsBrowser.destroy({ shuttingDown: false }); + } + break; + } + }, + + _observersRegistered: false, + + /** + * This function is for the benefit of Tools:{toolId} commands, + * triggered from the WebDeveloper menu and keyboard shortcuts. + * + * selectToolCommand's behavior: + * - if the current page is about:devtools-toolbox + * we select the targeted tool + * - if the toolbox is closed, + * we open the toolbox and select the tool + * - if the toolbox is open, and the targeted tool is not selected, + * we select it + * - if the toolbox is open, and the targeted tool is selected, + * and the host is NOT a window, we close the toolbox + * - if the toolbox is open, and the targeted tool is selected, + * and the host is a window, we raise the toolbox window + * + * Used when: - registering a new tool + * - new xul window, to add menu items + */ + async selectToolCommand(win, toolId, startTime) { + if (gDevToolsBrowser._isAboutDevtoolsToolbox(win)) { + const toolbox = gDevToolsBrowser._getAboutDevtoolsToolbox(win); + await toolbox.selectTool(toolId, "key_shortcut"); + return; + } + + const tab = win.gBrowser.selectedTab; + const toolbox = gDevTools.getToolboxForTab(tab); + const toolDefinition = gDevTools.getToolDefinition(toolId); + + if ( + toolbox && + (toolbox.currentToolId == toolId || + (toolId == "webconsole" && toolbox.splitConsole)) + ) { + toolbox.fireCustomKey(toolId); + + if ( + toolDefinition.preventClosingOnKey || + toolbox.hostType == Toolbox.HostType.WINDOW + ) { + if (!toolDefinition.preventRaisingOnKey) { + await toolbox.raise(); + } + } else { + await toolbox.destroy(); + } + gDevTools.emit("select-tool-command", toolId); + } else { + await gDevTools + .showToolboxForTab(tab, { + raise: !toolDefinition.preventRaisingOnKey, + startTime, + toolId, + }) + .then(newToolbox => { + newToolbox.fireCustomKey(toolId); + gDevTools.emit("select-tool-command", toolId); + }); + } + }, + + /** + * Called by devtools/client/devtools-startup.js when a key shortcut is pressed + * + * @param {Window} window + * The top level browser window from which the key shortcut is pressed. + * @param {Object} key + * Key object describing the key shortcut being pressed. It comes + * from devtools-startup.js's KeyShortcuts array. The useful fields here + * are: + * - `toolId` used to identify a toolbox's panel like inspector or webconsole, + * - `id` used to identify any other key shortcuts like about:debugging + * @param {Number} startTime + * Optional, indicates the time at which the key event fired. This is a + * `Cu.now()` timing. + */ + async onKeyShortcut(window, key, startTime) { + // Avoid to open devtools when the about:devtools-toolbox page is showing + // on the window now. + if ( + gDevToolsBrowser._isAboutDevtoolsToolbox(window) && + (key.id === "toggleToolbox" || key.id === "toggleToolboxF12") + ) { + return; + } + + // If this is a toolbox's panel key shortcut, delegate to selectToolCommand + if (key.toolId) { + await gDevToolsBrowser.selectToolCommand(window, key.toolId, startTime); + return; + } + // Otherwise implement all other key shortcuts individually here + switch (key.id) { + case "toggleToolbox": + gDevToolsBrowser.toggleToolboxCommand(window.gBrowser, startTime); + break; + case "toggleToolboxF12": + if (Services.prefs.getBoolPref(DEVTOOLS_F12_ENABLED_PREF, true)) { + gDevToolsBrowser.toggleToolboxCommand(window.gBrowser, startTime); + } + break; + case "browserToolbox": + lazy.BrowserToolboxLauncher.init(); + break; + case "browserConsole": + const { + BrowserConsoleManager, + } = require("resource://devtools/client/webconsole/browser-console-manager.js"); + BrowserConsoleManager.openBrowserConsoleOrFocus(); + break; + case "responsiveDesignMode": + ResponsiveUIManager.toggle(window, window.gBrowser.selectedTab, { + trigger: "shortcut", + }); + break; + case "javascriptTracingToggle": + const toolbox = gDevTools.getToolboxForTab(window.gBrowser.selectedTab); + if (!toolbox) { + break; + } + await toolbox.commands.tracerCommand.toggle(); + break; + } + }, + + /** + * Open a tab on "about:debugging", optionally pre-select a given tab. + */ + // Used by browser-sets.inc, command + openAboutDebugging(gBrowser, hash) { + const url = "about:debugging" + (hash ? "#" + hash : ""); + gBrowser.selectedTab = gBrowser.addTrustedTab(url); + }, + + /** + * Add the devtools-browser stylesheet to browser window's document. Returns a promise. + * + * @param {Window} win + * The window on which the stylesheet should be added. + * @return {Promise} promise that resolves when the stylesheet is loaded (or rejects + * if it fails to load). + */ + loadBrowserStyleSheet(win) { + if (this._browserStyleSheets.has(win)) { + return Promise.resolve(); + } + + const doc = win.document; + const { styleSheet, loadPromise } = appendStyleSheet( + doc, + BROWSER_STYLESHEET_URL + ); + this._browserStyleSheets.set(win, styleSheet); + return loadPromise; + }, + + /** + * Add this DevTools's presence to a browser window's document + * + * @param {HTMLDocument} doc + * The document to which devtools should be hooked to. + */ + _registerBrowserWindow(win) { + if (gDevToolsBrowser._trackedBrowserWindows.has(win)) { + return; + } + if (!win.document.getElementById("menuWebDeveloperPopup")) { + // Menus etc. set up here are browser specific. + return; + } + gDevToolsBrowser._trackedBrowserWindows.add(win); + BrowserMenus.addMenus(win.document); + + this.updateCommandAvailability(win); + this.updateDevtoolsThemeAttribute(win); + if (!this._observersRegistered) { + this._observersRegistered = true; + Services.prefs.addObserver("devtools.", this); + this._onThemeChanged = this._onThemeChanged.bind(this); + addThemeObserver(this._onThemeChanged); + } + + win.addEventListener("unload", this); + + const tabContainer = win.gBrowser.tabContainer; + tabContainer.addEventListener("TabSelect", this); + }, + + _onThemeChanged() { + for (const win of this._trackedBrowserWindows) { + this.updateDevtoolsThemeAttribute(win); + } + }, + + /** + * Add the menuitem for a tool to all open browser windows. + * + * @param {object} toolDefinition + * properties of the tool to add + */ + _addToolToWindows(toolDefinition) { + // No menu item or global shortcut is required for options panel. + if (!toolDefinition.inMenu) { + return; + } + + // Skip if the tool is disabled. + try { + if ( + toolDefinition.visibilityswitch && + !Services.prefs.getBoolPref(toolDefinition.visibilityswitch) + ) { + return; + } + } catch (e) { + // Prevent breaking everything if the pref doesn't exists. + } + + // We need to insert the new tool in the right place, which means knowing + // the tool that comes before the tool that we're trying to add + const allDefs = gDevTools.getToolDefinitionArray(); + let prevDef; + for (const def of allDefs) { + if (!def.inMenu) { + continue; + } + if (def === toolDefinition) { + break; + } + prevDef = def; + } + + for (const win of gDevToolsBrowser._trackedBrowserWindows) { + BrowserMenus.insertToolMenuElements( + win.document, + toolDefinition, + prevDef + ); + // If we are on a page where devtools menu items are hidden such as + // about:devtools-toolbox, we need to call _updateMenuItems to update the + // visibility of the newly created menu item. + gDevToolsBrowser._updateMenuItems(win); + } + }, + + hasToolboxOpened(win) { + const tab = win.gBrowser.selectedTab; + for (const commands of gDevTools._toolboxesPerCommands.keys()) { + if (commands.descriptorFront.localTab == tab) { + return true; + } + } + return false; + }, + + /** + * Update developer tools menu items and the "Toggle Tools" checkbox. This is + * called when a toolbox is created or destroyed. + */ + _updateMenu() { + for (const win of gDevToolsBrowser._trackedBrowserWindows) { + gDevToolsBrowser._updateMenuItems(win); + } + }, + + /** + * Update developer tools menu items and the "Toggle Tools" checkbox of XULWindow. + * + * @param {XULWindow} win + */ + _updateMenuItems(win) { + const menu = win.document.getElementById("menu_devToolbox"); + + // Hide the "Toggle Tools" menu item if we are on about:devtools-toolbox. + menu.hidden = + gDevToolsBrowser._isAboutDevtoolsToolbox(win) || + Services.prefs.getBoolPref("devtools.policy.disabled", false); + + // Add a checkmark for the "Toggle Tools" menu item if a toolbox is already opened. + const hasToolbox = gDevToolsBrowser.hasToolboxOpened(win); + if (hasToolbox) { + menu.setAttribute("checked", "true"); + } else { + menu.removeAttribute("checked"); + } + }, + + /** + * Check whether the window is showing about:devtools-toolbox page or not. + * + * @param {XULWindow} win + * @return {boolean} true: about:devtools-toolbox is showing + * false: otherwise + */ + _isAboutDevtoolsToolbox(win) { + const currentURI = win.gBrowser.currentURI; + return ( + currentURI.scheme === "about" && + currentURI.filePath === "devtools-toolbox" + ); + }, + + /** + * Retrieve the Toolbox instance loaded in the current page if the page is + * about:devtools-toolbox, null otherwise. + * + * @param {XULWindow} win + * The chrome window containing about:devtools-toolbox. Will match + * toolbox.topWindow. + * @return {Toolbox} The toolbox instance loaded in about:devtools-toolbox + * + */ + _getAboutDevtoolsToolbox(win) { + if (!gDevToolsBrowser._isAboutDevtoolsToolbox(win)) { + return null; + } + return gDevTools.getToolboxes().find(toolbox => toolbox.topWindow === win); + }, + + /** + * Remove the menuitem for a tool to all open browser windows. + * + * @param {string} toolId + * id of the tool to remove + */ + _removeToolFromWindows(toolId) { + for (const win of gDevToolsBrowser._trackedBrowserWindows) { + BrowserMenus.removeToolFromMenu(toolId, win.document); + } + }, + + /** + * Called on browser unload to remove menu entries, toolboxes and event + * listeners from the closed browser window. + * + * @param {XULWindow} win + * The window containing the menu entry + */ + _forgetBrowserWindow(win) { + if (!gDevToolsBrowser._trackedBrowserWindows.has(win)) { + return; + } + gDevToolsBrowser._trackedBrowserWindows.delete(win); + win.removeEventListener("unload", this); + + BrowserMenus.removeMenus(win.document); + + // Destroy toolboxes for closed window + for (const [commands, toolbox] of gDevTools._toolboxesPerCommands) { + if ( + commands.descriptorFront.localTab?.ownerDocument?.defaultView == win + ) { + toolbox.destroy(); + } + } + + const styleSheet = this._browserStyleSheets.get(win); + if (styleSheet) { + styleSheet.remove(); + this._browserStyleSheets.delete(win); + } + + const tabContainer = win.gBrowser.tabContainer; + tabContainer.removeEventListener("TabSelect", this); + }, + + handleEvent(event) { + switch (event.type) { + case "TabSelect": + gDevToolsBrowser._updateMenu(); + break; + case "unload": + // top-level browser window unload + gDevToolsBrowser._forgetBrowserWindow(event.target.defaultView); + break; + } + }, + + /** + * Either the DevTools Loader has been destroyed by the add-on contribution + * workflow, or firefox is shutting down. + + * @param {boolean} shuttingDown + * True if firefox is currently shutting down. We may prevent doing + * some cleanups to speed it up. Otherwise everything need to be + * cleaned up in order to be able to load devtools again. + */ + destroy({ shuttingDown }) { + Services.prefs.removeObserver("devtools.", gDevToolsBrowser); + removeThemeObserver(this._onThemeChanged); + Services.obs.removeObserver( + gDevToolsBrowser, + "browser-delayed-startup-finished" + ); + Services.obs.removeObserver(gDevToolsBrowser, "quit-application"); + Services.obs.removeObserver(gDevToolsBrowser, "devtools:loader:destroy"); + + for (const win of gDevToolsBrowser._trackedBrowserWindows) { + gDevToolsBrowser._forgetBrowserWindow(win); + } + + // Remove scripts loaded in content process to support the Browser Content Toolbox. + DevToolsServer.removeContentServerScript(); + + gDevTools.destroy({ shuttingDown }); + }, +}); + +// Handle all already registered tools, +gDevTools + .getToolDefinitionArray() + .forEach(def => gDevToolsBrowser._addToolToWindows(def)); +// and the new ones. +gDevTools.on("tool-registered", function (toolId) { + const toolDefinition = gDevTools._tools.get(toolId); + // If the tool has been registered globally, add to all the + // available windows. + if (toolDefinition) { + gDevToolsBrowser._addToolToWindows(toolDefinition); + } +}); + +gDevTools.on("tool-unregistered", function (toolId) { + gDevToolsBrowser._removeToolFromWindows(toolId); +}); + +gDevTools.on("toolbox-ready", gDevToolsBrowser._updateMenu); +gDevTools.on("toolbox-destroyed", gDevToolsBrowser._updateMenu); + +Services.obs.addObserver(gDevToolsBrowser, "quit-application"); +Services.obs.addObserver(gDevToolsBrowser, "browser-delayed-startup-finished"); +// Watch for module loader unload. Fires when the tools are reloaded. +Services.obs.addObserver(gDevToolsBrowser, "devtools:loader:destroy"); + +// Fake end of browser window load event for all already opened windows +// that is already fully loaded. +for (const win of Services.wm.getEnumerator(gDevTools.chromeWindowType)) { + if (win.gBrowserInit?.delayedStartupFinished) { + gDevToolsBrowser._registerBrowserWindow(win); + } +} diff --git a/devtools/client/framework/devtools.js b/devtools/client/framework/devtools.js new file mode 100644 index 0000000000..e56efb0c4b --- /dev/null +++ b/devtools/client/framework/devtools.js @@ -0,0 +1,998 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { DevToolsShim } = ChromeUtils.importESModule( + "chrome://devtools-startup/content/DevToolsShim.sys.mjs" +); + +const { DEFAULT_SANDBOX_NAME } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + BrowserToolboxLauncher: + "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs", +}); + +loader.lazyRequireGetter( + this, + "LocalTabCommandsFactory", + "resource://devtools/client/framework/local-tab-commands-factory.js", + true +); +loader.lazyRequireGetter( + this, + "CommandsFactory", + "resource://devtools/shared/commands/commands-factory.js", + true +); +loader.lazyRequireGetter( + this, + "ToolboxHostManager", + "resource://devtools/client/framework/toolbox-host-manager.js", + true +); +loader.lazyRequireGetter( + this, + "BrowserConsoleManager", + "resource://devtools/client/webconsole/browser-console-manager.js", + true +); +loader.lazyRequireGetter( + this, + "Toolbox", + "resource://devtools/client/framework/toolbox.js", + true +); + +loader.lazyRequireGetter( + this, + "Telemetry", + "resource://devtools/client/shared/telemetry.js" +); + +const { + defaultTools: DefaultTools, + defaultThemes: DefaultThemes, +} = require("resource://devtools/client/definitions.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const { + getTheme, + setTheme, + getAutoTheme, + addThemeObserver, + removeThemeObserver, +} = require("resource://devtools/client/shared/theme.js"); + +const FORBIDDEN_IDS = new Set(["toolbox", ""]); +const MAX_ORDINAL = 99; +const POPUP_DEBUG_PREF = "devtools.popups.debug"; +const DEVTOOLS_ALWAYS_ON_TOP = "devtools.toolbox.alwaysOnTop"; + +/** + * DevTools is a class that represents a set of developer tools, it holds a + * set of tools and keeps track of open toolboxes in the browser. + */ +function DevTools() { + // We should be careful to always load a unique instance of this module: + // - only in the parent process + // - only in the "shared JSM global" spawn by mozJSModuleLoader + // The server codebase typically use another global named "DevTools global", + // which will load duplicated instances of all the modules -or- another + // DevTools module loader named "DevTools (Server Module loader)". + // Also the realm location is appended the loading callsite, so only check + // the beginning of the string. + if ( + Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT || + !Cu.getRealmLocation(globalThis).startsWith(DEFAULT_SANDBOX_NAME) + ) { + throw new Error( + "This module should be loaded in the parent process only, in the shared global." + ); + } + + this._tools = new Map(); // Map<toolId, tool> + this._themes = new Map(); // Map<themeId, theme> + this._toolboxesPerCommands = new Map(); // Map<commands, toolbox> + // List of toolboxes that are still in process of creation + this._creatingToolboxes = new Map(); // Map<commands, toolbox Promise> + + EventEmitter.decorate(this); + this._telemetry = new Telemetry(); + this._telemetry.setEventRecordingEnabled(true); + + // List of all commands of debugged local Web Extension. + this._commandsPromiseByWebExtId = new Map(); // Map<extensionId, commands> + + // Listen for changes to the theme pref. + this._onThemeChanged = this._onThemeChanged.bind(this); + addThemeObserver(this._onThemeChanged); + + // This is important step in initialization codepath where we are going to + // start registering all default tools and themes: create menuitems, keys, emit + // related events. + this.registerDefaults(); + + // Register this DevTools instance on the DevToolsShim, which is used by non-devtools + // code to interact with DevTools. + DevToolsShim.register(this); +} + +DevTools.prototype = { + // The windowtype of the main window, used in various tools. This may be set + // to something different by other gecko apps. + chromeWindowType: "navigator:browser", + + registerDefaults() { + // Ensure registering items in the sorted order (getDefault* functions + // return sorted lists) + this.getDefaultTools().forEach(definition => this.registerTool(definition)); + this.getDefaultThemes().forEach(definition => + this.registerTheme(definition) + ); + }, + + unregisterDefaults() { + for (const definition of this.getToolDefinitionArray()) { + this.unregisterTool(definition.id); + } + for (const definition of this.getThemeDefinitionArray()) { + this.unregisterTheme(definition.id); + } + }, + + /** + * Register a new developer tool. + * + * A definition is a light object that holds different information about a + * developer tool. This object is not supposed to have any operational code. + * See it as a "manifest". + * The only actual code lives in the build() function, which will be used to + * start an instance of this tool. + * + * Each toolDefinition has the following properties: + * - id: Unique identifier for this tool (string|required) + * - visibilityswitch: Property name to allow us to hide this tool from the + * DevTools Toolbox. + * A falsy value indicates that it cannot be hidden. + * - icon: URL pointing to a graphic which will be used as the src for an + * 16x16 img tag (string|required) + * - url: URL pointing to a XUL/XHTML document containing the user interface + * (string|required) + * - label: Localized name for the tool to be displayed to the user + * (string|required) + * - hideInOptions: Boolean indicating whether or not this tool should be + shown in toolbox options or not. Defaults to false. + * (boolean) + * - build: Function that takes an iframe, which has been populated with the + * markup from |url|, and also the toolbox containing the panel. + * And returns an instance of ToolPanel (function|required) + */ + registerTool(toolDefinition) { + const toolId = toolDefinition.id; + + if (!toolId || FORBIDDEN_IDS.has(toolId)) { + throw new Error("Invalid definition.id"); + } + + // Make sure that additional tools will always be able to be hidden. + // When being called from main.js, defaultTools has not yet been exported. + // But, we can assume that in this case, it is a default tool. + if (!DefaultTools.includes(toolDefinition)) { + toolDefinition.visibilityswitch = "devtools." + toolId + ".enabled"; + } + + this._tools.set(toolId, toolDefinition); + + this.emit("tool-registered", toolId); + }, + + /** + * Removes all tools that match the given |toolId| + * Needed so that add-ons can remove themselves when they are deactivated + * + * @param {string|object} tool + * Definition or the id of the tool to unregister. Passing the + * tool id should be avoided as it is a temporary measure. + * @param {boolean} isQuitApplication + * true to indicate that the call is due to app quit, so we should not + * cause a cascade of costly events + */ + unregisterTool(tool, isQuitApplication) { + let toolId = null; + if (typeof tool == "string") { + toolId = tool; + tool = this._tools.get(tool); + } else { + const { Deprecated } = ChromeUtils.importESModule( + "resource://gre/modules/Deprecated.sys.mjs" + ); + Deprecated.warning( + "Deprecation WARNING: gDevTools.unregisterTool(tool) is " + + "deprecated. You should unregister a tool using its toolId: " + + "gDevTools.unregisterTool(toolId)." + ); + toolId = tool.id; + } + this._tools.delete(toolId); + + if (!isQuitApplication) { + this.emit("tool-unregistered", toolId); + } + }, + + /** + * Sorting function used for sorting tools based on their ordinals. + */ + ordinalSort(d1, d2) { + const o1 = typeof d1.ordinal == "number" ? d1.ordinal : MAX_ORDINAL; + const o2 = typeof d2.ordinal == "number" ? d2.ordinal : MAX_ORDINAL; + return o1 - o2; + }, + + getDefaultTools() { + return DefaultTools.sort(this.ordinalSort); + }, + + getAdditionalTools() { + const tools = []; + for (const [, value] of this._tools) { + if (!DefaultTools.includes(value)) { + tools.push(value); + } + } + return tools.sort(this.ordinalSort); + }, + + getDefaultThemes() { + return DefaultThemes.sort(this.ordinalSort); + }, + + /** + * Get a tool definition if it exists and is enabled. + * + * @param {string} toolId + * The id of the tool to show + * + * @return {ToolDefinition|null} tool + * The ToolDefinition for the id or null. + */ + getToolDefinition(toolId) { + const tool = this._tools.get(toolId); + if (!tool) { + return null; + } else if (!tool.visibilityswitch) { + return tool; + } + + const enabled = Services.prefs.getBoolPref(tool.visibilityswitch, true); + + return enabled ? tool : null; + }, + + /** + * Allow ToolBoxes to get at the list of tools that they should populate + * themselves with. + * + * @return {Map} tools + * A map of the the tool definitions registered in this instance + */ + getToolDefinitionMap() { + const tools = new Map(); + + for (const [id, definition] of this._tools) { + if (this.getToolDefinition(id)) { + tools.set(id, definition); + } + } + + return tools; + }, + + /** + * Tools have an inherent ordering that can't be represented in a Map so + * getToolDefinitionArray provides an alternative representation of the + * definitions sorted by ordinal value. + * + * @return {Array} tools + * A sorted array of the tool definitions registered in this instance + */ + getToolDefinitionArray() { + const definitions = []; + + for (const [id, definition] of this._tools) { + if (this.getToolDefinition(id)) { + definitions.push(definition); + } + } + + return definitions.sort(this.ordinalSort); + }, + + /** + * Returns the name of the current theme for devtools. + * + * @return {string} theme + * The name of the current devtools theme. + */ + getTheme() { + return getTheme(); + }, + + /** + * Returns the name of the default (auto) theme for devtools. + * + * @return {string} theme + */ + getAutoTheme() { + return getAutoTheme(); + }, + + /** + * Called when the developer tools theme changes. + */ + _onThemeChanged() { + this.emit("theme-changed", getTheme()); + }, + + /** + * Register a new theme for developer tools toolbox. + * + * A definition is a light object that holds various information about a + * theme. + * + * Each themeDefinition has the following properties: + * - id: Unique identifier for this theme (string|required) + * - label: Localized name for the theme to be displayed to the user + * (string|required) + * - stylesheets: Array of URLs pointing to a CSS document(s) containing + * the theme style rules (array|required) + * - classList: Array of class names identifying the theme within a document. + * These names are set to document element when applying + * the theme (array|required) + * - onApply: Function that is executed by the framework when the theme + * is applied. The function takes the current iframe window + * and the previous theme id as arguments (function) + * - onUnapply: Function that is executed by the framework when the theme + * is unapplied. The function takes the current iframe window + * and the new theme id as arguments (function) + */ + registerTheme(themeDefinition) { + const themeId = themeDefinition.id; + + if (!themeId) { + throw new Error("Invalid theme id"); + } + + if (this._themes.get(themeId)) { + throw new Error("Theme with the same id is already registered"); + } + + this._themes.set(themeId, themeDefinition); + + this.emit("theme-registered", themeId); + }, + + /** + * Removes an existing theme from the list of registered themes. + * Needed so that add-ons can remove themselves when they are deactivated + * + * @param {string|object} theme + * Definition or the id of the theme to unregister. + */ + unregisterTheme(theme) { + let themeId = null; + if (typeof theme == "string") { + themeId = theme; + theme = this._themes.get(theme); + } else { + themeId = theme.id; + } + + const currTheme = getTheme(); + + // Note that we can't check if `theme` is an item + // of `DefaultThemes` as we end up reloading definitions + // module and end up with different theme objects + const isCoreTheme = DefaultThemes.some(t => t.id === themeId); + + // Reset the theme if an extension theme that's currently applied + // is being removed. + // Ignore shutdown since addons get disabled during that time. + if ( + !Services.startup.shuttingDown && + !isCoreTheme && + theme.id == currTheme + ) { + setTheme("auto"); + + this.emit("theme-unregistered", theme); + } + + this._themes.delete(themeId); + }, + + /** + * Get a theme definition if it exists. + * + * @param {string} themeId + * The id of the theme + * + * @return {ThemeDefinition|null} theme + * The ThemeDefinition for the id or null. + */ + getThemeDefinition(themeId) { + const theme = this._themes.get(themeId); + if (!theme) { + return null; + } + return theme; + }, + + /** + * Get map of registered themes. + * + * @return {Map} themes + * A map of the the theme definitions registered in this instance + */ + getThemeDefinitionMap() { + const themes = new Map(); + + for (const [id, definition] of this._themes) { + if (this.getThemeDefinition(id)) { + themes.set(id, definition); + } + } + + return themes; + }, + + /** + * Get registered themes definitions sorted by ordinal value. + * + * @return {Array} themes + * A sorted array of the theme definitions registered in this instance + */ + getThemeDefinitionArray() { + const definitions = []; + + for (const [id, definition] of this._themes) { + if (this.getThemeDefinition(id)) { + definitions.push(definition); + } + } + + return definitions.sort(this.ordinalSort); + }, + + /** + * Called from SessionStore.sys.mjs in mozilla-central when saving the current state. + * + * @param {Object} state + * A SessionStore state object that gets modified by reference + */ + saveDevToolsSession(state) { + state.browserConsole = + BrowserConsoleManager.getBrowserConsoleSessionState(); + state.browserToolbox = + lazy.BrowserToolboxLauncher.getBrowserToolboxSessionState(); + }, + + /** + * Restore the devtools session state as provided by SessionStore. + */ + async restoreDevToolsSession({ browserConsole, browserToolbox }) { + if (browserToolbox) { + lazy.BrowserToolboxLauncher.init(); + } + + if (browserConsole && !BrowserConsoleManager.getBrowserConsole()) { + await BrowserConsoleManager.toggleBrowserConsole(); + } + }, + + /** + * Boolean, true, if we never opened a toolbox. + * Used to implement the telemetry tracking toolbox opening. + */ + _firstShowToolbox: true, + + /** + * Show a Toolbox for a given "commands" (either by creating a new one, or if a + * toolbox already exists for the commands, by bringing to the front the + * existing one). + * + * If a Toolbox already exists, we will still update it based on some of the + * provided parameters: + * - if |toolId| is provided then the toolbox will switch to the specified + * tool. + * - if |hostType| is provided then the toolbox will be switched to the + * specified HostType. + * + * @param {Commands Object} commands + * The commands object which designates which context the toolbox will debug + * @param {Object} + * - {String} toolId + * The id of the tool to show + * - {Toolbox.HostType} hostType + * The type of host (bottom, window, left, right) + * - {object} hostOptions + * Options for host specifically + * - {Number} startTime + * Indicates the time at which the user event related to + * this toolbox opening started. This is a `Cu.now()` timing. + * - {string} reason + * Reason the tool was opened + * - {boolean} raise + * Whether we need to raise the toolbox or not. + * + * @return {Toolbox} toolbox + * The toolbox that was opened + */ + async showToolbox( + commands, + { + toolId, + hostType, + startTime, + raise = true, + reason = "toolbox_show", + hostOptions, + } = {} + ) { + let toolbox = this._toolboxesPerCommands.get(commands); + + if (toolbox) { + if (hostType != null && toolbox.hostType != hostType) { + await toolbox.switchHost(hostType); + } + + if (toolId != null) { + // selectTool will either select the tool if not currently selected, or wait for + // the tool to be loaded if needed. + await toolbox.selectTool(toolId, reason); + } + + if (raise) { + await toolbox.raise(); + } + } else { + // Toolbox creation is async, we have to be careful about races. + // Check if we are already waiting for a Toolbox for the provided + // commands before creating a new one. + const promise = this._creatingToolboxes.get(commands); + if (promise) { + return promise; + } + const toolboxPromise = this._createToolbox( + commands, + toolId, + hostType, + hostOptions + ); + this._creatingToolboxes.set(commands, toolboxPromise); + toolbox = await toolboxPromise; + this._creatingToolboxes.delete(commands); + + if (startTime) { + this.logToolboxOpenTime(toolbox, startTime); + } + this._firstShowToolbox = false; + } + + // We send the "enter" width here to ensure it is always sent *after* + // the "open" event. + const width = Math.ceil(toolbox.win.outerWidth / 50) * 50; + const panelName = this.makeToolIdHumanReadable( + toolId || toolbox.defaultToolId + ); + this._telemetry.addEventProperty( + toolbox, + "enter", + panelName, + null, + "width", + width + ); + + return toolbox; + }, + + /** + * Show the toolbox for a given tab. If a toolbox already exists for this tab + * the existing toolbox will be raised. Otherwise a new toolbox is created. + * + * Relies on `showToolbox`, see its jsDoc for additional information and + * arguments description. + * + * Also used by 3rd party tools (eg wptrunner) and exposed by + * DevToolsShim.sys.mjs. + * + * @param {XULTab} tab + * The tab the toolbox will debug + * @param {Object} options + * Various options that will be forwarded to `showToolbox`. See the + * JSDoc on this method. + */ + async showToolboxForTab( + tab, + { toolId, hostType, startTime, raise, reason, hostOptions } = {} + ) { + // Popups are debugged via the toolbox of their opener document/tab. + // So avoid opening dedicated toolbox for them. + if ( + tab.linkedBrowser.browsingContext.opener && + Services.prefs.getBoolPref(POPUP_DEBUG_PREF) + ) { + const openerTab = tab.ownerGlobal.gBrowser.getTabForBrowser( + tab.linkedBrowser.browsingContext.opener.embedderElement + ); + const openerCommands = await LocalTabCommandsFactory.getCommandsForTab( + openerTab + ); + if (this.getToolboxForCommands(openerCommands)) { + console.log( + "Can't open a toolbox for this document as this is debugged from its opener tab" + ); + return null; + } + } + const commands = await LocalTabCommandsFactory.createCommandsForTab(tab); + return this.showToolbox(commands, { + toolId, + hostType, + startTime, + raise, + reason, + hostOptions, + }); + }, + + /** + * Open a Toolbox in a dedicated top-level window for debugging a local WebExtension. + * This will re-open a previously opened toolbox if we try to re-debug the same extension. + * + * Note that this will spawn a new DevToolsClient. + * + * @param {String} extensionId + * ID of the extension to debug. + * @param {Object} (optional) + * - {String} toolId + * The id of the tool to show + */ + async showToolboxForWebExtension(extensionId, { toolId } = {}) { + // Ensure spawning only one commands instance per extension at a time by caching its commands. + // showToolbox will later reopen the previously opened toolbox if called with the same + // commands. + let commandsPromise = this._commandsPromiseByWebExtId.get(extensionId); + if (!commandsPromise) { + commandsPromise = CommandsFactory.forAddon(extensionId); + this._commandsPromiseByWebExtId.set(extensionId, commandsPromise); + } + const commands = await commandsPromise; + commands.client.once("closed").then(() => { + this._commandsPromiseByWebExtId.delete(extensionId); + }); + + return this.showToolbox(commands, { + hostType: Toolbox.HostType.WINDOW, + hostOptions: { + // The toolbox is always displayed on top so that we can keep + // the DevTools visible while interacting with the Firefox window. + alwaysOnTop: Services.prefs.getBoolPref(DEVTOOLS_ALWAYS_ON_TOP, false), + }, + toolId, + }); + }, + + /** + * Log telemetry related to toolbox opening. + * Two distinct probes are logged. One for cold startup, when we open the very first + * toolbox. This one includes devtools framework loading. And a second one for all + * subsequent toolbox opening, which should all be faster. + * These two probes are indexed by Tool ID. + * + * @param {String} toolbox + * Toolbox instance. + * @param {Number} startTime + * Indicates the time at which the user event related to the toolbox + * opening started. This is a `Cu.now()` timing. + */ + logToolboxOpenTime(toolbox, startTime) { + const toolId = toolbox.currentToolId || toolbox.defaultToolId; + const delay = Cu.now() - startTime; + const panelName = this.makeToolIdHumanReadable(toolId); + + const telemetryKey = this._firstShowToolbox + ? "DEVTOOLS_COLD_TOOLBOX_OPEN_DELAY_MS" + : "DEVTOOLS_WARM_TOOLBOX_OPEN_DELAY_MS"; + this._telemetry.getKeyedHistogramById(telemetryKey).add(toolId, delay); + + const browserWin = toolbox.topWindow; + this._telemetry.addEventProperty( + browserWin, + "open", + "tools", + null, + "first_panel", + panelName + ); + }, + + makeToolIdHumanReadable(toolId) { + if (/^[0-9a-fA-F]{40}_temporary-addon/.test(toolId)) { + return "temporary-addon"; + } + + let matches = toolId.match( + /^_([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})_/ + ); + if (matches && matches.length === 2) { + return matches[1]; + } + + matches = toolId.match(/^_?(.*)-\d+-\d+-devtools-panel$/); + if (matches && matches.length === 2) { + return matches[1]; + } + + return toolId; + }, + + /** + * Unconditionally create a new Toolbox instance for the provided commands. + * See `showToolbox` for the arguments' jsdoc. + */ + async _createToolbox(commands, toolId, hostType, hostOptions) { + const manager = new ToolboxHostManager(commands, hostType, hostOptions); + + const toolbox = await manager.create(toolId); + + this._toolboxesPerCommands.set(commands, toolbox); + + toolbox.once("destroy", () => { + this.emit("toolbox-destroy", toolbox); + }); + + toolbox.once("destroyed", () => { + this._toolboxesPerCommands.delete(commands); + this.emit("toolbox-destroyed", toolbox); + }); + + await toolbox.open(); + this.emit("toolbox-ready", toolbox); + + return toolbox; + }, + + /** + * Return the toolbox for a given commands object. + * + * @param {Commands Object} commands + * Debugging context commands that owns this toolbox + * + * @return {Toolbox} toolbox + * The toolbox that is debugging the given context designated by the commands + */ + getToolboxForCommands(commands) { + return this._toolboxesPerCommands.get(commands); + }, + + /** + * TabDescriptorFront requires a synchronous method and don't have a reference to its + * related commands object. So expose something handcrafted just for this. + */ + getToolboxForDescriptorFront(descriptorFront) { + for (const [commands, toolbox] of this._toolboxesPerCommands) { + if (commands.descriptorFront == descriptorFront) { + return toolbox; + } + } + return null; + }, + + /** + * Retrieve an existing toolbox for the provided tab if it was created before. + * Returns null otherwise. + * + * @param {XULTab} tab + * The browser tab. + * @return {Toolbox} + * Returns tab's toolbox object. + */ + getToolboxForTab(tab) { + return this.getToolboxes().find( + t => t.commands.descriptorFront.localTab === tab + ); + }, + + /** + * Close the toolbox for a given tab. + * + * @return {Promise} Returns a promise that resolves either: + * - immediately if no Toolbox was found + * - or after toolbox.destroy() resolved if a Toolbox was found + */ + async closeToolboxForTab(tab) { + const commands = await LocalTabCommandsFactory.getCommandsForTab(tab); + + let toolbox = await this._creatingToolboxes.get(commands); + if (!toolbox) { + toolbox = this._toolboxesPerCommands.get(commands); + } + if (!toolbox) { + return; + } + await toolbox.destroy(); + }, + + /** + * Compatibility layer for web-extensions. Used by DevToolsShim for + * browser/components/extensions/ext-devtools.js + * + * web-extensions need to use dedicated instances of Commands and cannot reuse the + * cached instances managed by DevTools. + * Note that is will end up being cached in WebExtension codebase, via + * DevToolsExtensionPageContextParent.getDevToolsCommands. + */ + createCommandsForTabForWebExtension(tab) { + return CommandsFactory.forTab(tab, { isWebExtension: true }); + }, + + /** + * Compatibility layer for web-extensions. Used by DevToolsShim for + * toolkit/components/extensions/ext-c-toolkit.js + */ + openBrowserConsole() { + const { + BrowserConsoleManager, + } = require("resource://devtools/client/webconsole/browser-console-manager.js"); + BrowserConsoleManager.openBrowserConsoleOrFocus(); + }, + + /** + * Called from the DevToolsShim, used by nsContextMenu.js. + * + * @param {XULTab} tab + * The browser tab on which inspect node was used. + * @param {ElementIdentifier} domReference + * Identifier generated by ContentDOMReference. It is a unique pair of + * BrowsingContext ID and a numeric ID. + * @param {Number} startTime + * Optional, indicates the time at which the user event related to this node + * inspection started. This is a `Cu.now()` timing. + * @return {Promise} a promise that resolves when the node is selected in the inspector + * markup view. + */ + async inspectNode(tab, domReference, startTime) { + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "inspector", + startTime, + reason: "inspect_dom", + }); + const inspector = toolbox.getCurrentPanel(); + + const nodeFront = + await inspector.inspectorFront.getNodeActorFromContentDomReference( + domReference + ); + if (!nodeFront) { + return; + } + + // "new-node-front" tells us when the node has been selected, whether the + // browser is remote or not. + const onNewNode = inspector.selection.once("new-node-front"); + // Select the final node + inspector.selection.setNodeFront(nodeFront, { + reason: "browser-context-menu", + }); + + await onNewNode; + // Now that the node has been selected, wait until the inspector is + // fully updated. + await inspector.once("inspector-updated"); + }, + + /** + * Called from the DevToolsShim, used by nsContextMenu.js. + * + * @param {XULTab} tab + * The browser tab on which inspect accessibility was used. + * @param {ElementIdentifier} domReference + * Identifier generated by ContentDOMReference. It is a unique pair of + * BrowsingContext ID and a numeric ID. + * @param {Number} startTime + * Optional, indicates the time at which the user event related to this + * node inspection started. This is a `Cu.now()` timing. + * @return {Promise} a promise that resolves when the accessible object is + * selected in the accessibility inspector. + */ + async inspectA11Y(tab, domReference, startTime) { + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "accessibility", + startTime, + }); + const inspectorFront = await toolbox.target.getFront("inspector"); + const nodeFront = await inspectorFront.getNodeActorFromContentDomReference( + domReference + ); + if (!nodeFront) { + return; + } + + // Select the accessible object in the panel and wait for the event that + // tells us it has been done. + const a11yPanel = toolbox.getCurrentPanel(); + const onSelected = a11yPanel.once("new-accessible-front-selected"); + a11yPanel.selectAccessibleForNode(nodeFront, "browser-context-menu"); + await onSelected; + }, + + /** + * Either the DevTools Loader has been destroyed or firefox is shutting down. + * @param {boolean} shuttingDown + * True if firefox is currently shutting down. We may prevent doing + * some cleanups to speed it up. Otherwise everything need to be + * cleaned up in order to be able to load devtools again. + */ + destroy({ shuttingDown }) { + // Do not cleanup everything during firefox shutdown. + if (!shuttingDown) { + for (const [, toolbox] of this._toolboxesPerCommands) { + toolbox.destroy(); + } + } + + for (const [key] of this.getToolDefinitionMap()) { + this.unregisterTool(key, true); + } + + gDevTools.unregisterDefaults(); + + removeThemeObserver(this._onThemeChanged); + + // Do not unregister devtools from the DevToolsShim if the destroy is caused by an + // application shutdown. For instance SessionStore needs to save the Browser Toolbox + // state on shutdown. + if (!shuttingDown) { + // Notify the DevToolsShim that DevTools are no longer available, particularly if + // the destroy was caused by disabling/removing DevTools. + DevToolsShim.unregister(); + } + + // Cleaning down the toolboxes: i.e. + // for (let [, toolbox] of this._toolboxesPerCommands) toolbox.destroy(); + // Is taken care of by the gDevToolsBrowser.forgetBrowserWindow + }, + + /** + * Returns the array of the existing toolboxes. + * + * @return {Array<Toolbox>} + * An array of toolboxes. + */ + getToolboxes() { + return Array.from(this._toolboxesPerCommands.values()); + }, + + /** + * Returns whether the given tab has toolbox. + * + * @param {XULTab} tab + * The browser tab. + * @return {boolean} + * Returns true if the tab has toolbox. + */ + hasToolboxForTab(tab) { + return this.getToolboxes().some( + t => t.commands.descriptorFront.localTab === tab + ); + }, +}; + +const gDevTools = (exports.gDevTools = new DevTools()); diff --git a/devtools/client/framework/local-tab-commands-factory.js b/devtools/client/framework/local-tab-commands-factory.js new file mode 100644 index 0000000000..e7e32aa343 --- /dev/null +++ b/devtools/client/framework/local-tab-commands-factory.js @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +loader.lazyRequireGetter( + this, + "CommandsFactory", + "resource://devtools/shared/commands/commands-factory.js", + true +); + +// Map of existing Commands objects, keyed by XULTab. +const commandsMap = new WeakMap(); + +/** + * Functions for creating unique Commands for Local Tabs. + */ +exports.LocalTabCommandsFactory = { + /** + * Create a unique commands object for the given tab. + * + * If a commands was already created by this factory for the provided tab, + * it will be returned and no new commands created. + * + * Otherwise, this will automatically: + * - spawn a DevToolsServer in the parent process, + * - create a DevToolsClient + * - connect the DevToolsClient to the DevToolsServer + * - call RootActor's `getTab` request to retrieve the WindowGlobalTargetActor's form + * + * @param {XULTab} tab + * The tab to use in creating a new commands. + * + * @return {Commands object} The commands object for the provided tab. + */ + async createCommandsForTab(tab) { + let commands = commandsMap.get(tab); + if (commands) { + // Keep in mind that commands can be either a promise + // or a commands object. + return commands; + } + + const promise = CommandsFactory.forTab(tab); + // Immediately set the commands's promise in cache to prevent race + commandsMap.set(tab, promise); + commands = await promise; + // Then replace the promise with the commands object + commandsMap.set(tab, commands); + + commands.descriptorFront.once("descriptor-destroyed", () => { + commandsMap.delete(tab); + }); + return commands; + }, + + /** + * Retrieve an existing commands created by this factory for the provided + * tab. Returns null if no commands was created yet. + * + * @param {XULTab} tab + * The tab for which the commands should be retrieved + */ + async getCommandsForTab(tab) { + // commandsMap.get(tab) can either return an initialized commands, a promise + // which will resolve a commands, or null if no commands was ever created + // for this tab. + return commandsMap.get(tab); + }, +}; diff --git a/devtools/client/framework/menu-item.js b/devtools/client/framework/menu-item.js new file mode 100644 index 0000000000..dcfb12f93b --- /dev/null +++ b/devtools/client/framework/menu-item.js @@ -0,0 +1,79 @@ +/* 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"; + +/** + * A partial implementation of the MenuItem API provided by electron: + * https://github.com/electron/electron/blob/master/docs/api/menu-item.md. + * + * Missing features: + * - id String - Unique within a single menu. If defined then it can be used + * as a reference to this item by the position attribute. + * - role String - Define the action of the menu item; when specified the + * click property will be ignored + * - sublabel String + * - accelerator Accelerator + * - position String - This field allows fine-grained definition of the + * specific location within a given menu. + * + * Implemented features: + * @param Object options + * String accelerator + * Text that appears beside the menu label to indicate the shortcut key + * (accelerator key) to use to invoke the command. + * Unlike the Electron API, this is a label only and does not actually + * register a handler for the key. + * String accesskey [non-standard] + * A single character used as the shortcut key. This should be one of the + * characters that appears in the label. + * Function click + * Will be called with click(menuItem, browserWindow) when the menu item + * is clicked + * String type + * Can be normal, separator, submenu, checkbox or radio + * String label + * String image + * Boolean enabled + * If false, the menu item will be greyed out and unclickable. + * Boolean checked + * Should only be specified for checkbox or radio type menu items. + * Menu submenu + * Should be specified for submenu type menu items. If submenu is specified, + * the type: 'submenu' can be omitted. If the value is not a Menu then it + * will be automatically converted to one using Menu.buildFromTemplate. + * Boolean visible + * If false, the menu item will be entirely hidden. + */ +function MenuItem({ + accelerator = null, + accesskey = null, + l10nID = null, + checked = false, + click = () => {}, + disabled = false, + hover = () => {}, + id = null, + label = "", + image = null, + submenu = null, + type = "normal", + visible = true, +} = {}) { + this.accelerator = accelerator; + this.accesskey = accesskey; + this.l10nID = l10nID; + this.checked = checked; + this.click = click; + this.disabled = disabled; + this.hover = hover; + this.id = id; + this.label = label; + this.image = image; + this.submenu = submenu; + this.type = type; + this.visible = visible; +} + +module.exports = MenuItem; diff --git a/devtools/client/framework/menu.js b/devtools/client/framework/menu.js new file mode 100644 index 0000000000..a4cd8af5f7 --- /dev/null +++ b/devtools/client/framework/menu.js @@ -0,0 +1,248 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +/** + * A partial implementation of the Menu API provided by electron: + * https://github.com/electron/electron/blob/master/docs/api/menu.md. + * + * Extra features: + * - Emits an 'open' and 'close' event when the menu is opened/closed + + * @param String id (non standard) + * Needed so tests can confirm the XUL implementation is working + */ +function Menu({ id = null } = {}) { + this.menuitems = []; + this.id = id; + + Object.defineProperty(this, "items", { + get() { + return this.menuitems; + }, + }); + + EventEmitter.decorate(this); +} + +/** + * Add an item to the end of the Menu + * + * @param {MenuItem} menuItem + */ +Menu.prototype.append = function (menuItem) { + this.menuitems.push(menuItem); +}; + +/** + * Remove all items from the Menu + */ +Menu.prototype.clear = function () { + this.menuitems = []; +}; + +/** + * Add an item to a specified position in the menu + * + * @param {int} pos + * @param {MenuItem} menuItem + */ +Menu.prototype.insert = function (pos, menuItem) { + throw Error("Not implemented"); +}; + +/** + * Show the Menu next to the provided target. Anchor point is bottom-left. + * + * @param {Element} target + * The element to use as anchor. + */ +Menu.prototype.popupAtTarget = function (target) { + const rect = target.getBoundingClientRect(); + const doc = target.ownerDocument; + const defaultView = doc.defaultView; + const x = rect.left + defaultView.mozInnerScreenX; + const y = rect.bottom + defaultView.mozInnerScreenY; + + this.popup(x, y, doc); +}; + +/** + * Hide an existing menu, if there's any. + * + * @param {Document} doc + * The document that should own the context menu. + */ +Menu.prototype.hide = function (doc) { + const win = doc.defaultView; + doc = DevToolsUtils.getTopWindow(win).document; + const popup = doc.querySelector('popupset menupopup[menu-api="true"]'); + if (!popup) { + return; + } + popup.hidePopup(); +}; + +/** + * Show the Menu at a specified location on the screen + * + * Missing features: + * - browserWindow - BrowserWindow (optional) - Default is null. + * - positioningItem Number - (optional) OS X + * + * @param {int} screenX + * @param {int} screenY + * @param {Document} doc + * The document that should own the context menu. + */ +Menu.prototype.popup = function (screenX, screenY, doc) { + // See bug 1285229, on Windows, opening the same popup multiple times in a + // row ends up duplicating the popup. The newly inserted popup doesn't + // dismiss the old one. So remove any previously displayed popup before + // opening a new one. + this.hide(doc); + + // The context-menu will be created in the topmost window to preserve keyboard + // navigation (see Bug 1543940). + // Keep a reference on the window owning the menu to hide the popup on unload. + const win = doc.defaultView; + const topWin = DevToolsUtils.getTopWindow(win); + + // Convert coordinates from win's CSS coordinate space to topWin's + const winToTopWinCssScale = win.devicePixelRatio / topWin.devicePixelRatio; + screenX = screenX * winToTopWinCssScale; + screenY = screenY * winToTopWinCssScale; + + doc = topWin.document; + + let popupset = doc.querySelector("popupset"); + if (!popupset) { + popupset = doc.createXULElement("popupset"); + doc.documentElement.appendChild(popupset); + } + + const popup = doc.createXULElement("menupopup"); + popup.setAttribute("menu-api", "true"); + popup.setAttribute("consumeoutsideclicks", "false"); + popup.setAttribute("incontentshell", "false"); + + if (this.id) { + popup.id = this.id; + } + this._createMenuItems(popup); + + // The context menu will be created in the topmost chrome window. Hide it manually when + // the owner document is unloaded. + const onWindowUnload = () => popup.hidePopup(); + win.addEventListener("unload", onWindowUnload); + + // Remove the menu from the DOM once it's hidden. + popup.addEventListener("popuphidden", e => { + if (e.target === popup) { + win.removeEventListener("unload", onWindowUnload); + popup.remove(); + this.emit("close"); + } + }); + + popup.addEventListener("popupshown", e => { + if (e.target === popup) { + this.emit("open"); + } + }); + + popupset.appendChild(popup); + popup.openPopupAtScreen(screenX, screenY, true); +}; + +Menu.prototype._createMenuItems = function (parent) { + const doc = parent.ownerDocument; + this.menuitems.forEach(item => { + if (!item.visible) { + return; + } + + if (item.submenu) { + const menupopup = doc.createXULElement("menupopup"); + menupopup.setAttribute("incontentshell", "false"); + + item.submenu._createMenuItems(menupopup); + + const menu = doc.createXULElement("menu"); + menu.appendChild(menupopup); + applyItemAttributesToNode(item, menu); + parent.appendChild(menu); + } else if (item.type === "separator") { + const menusep = doc.createXULElement("menuseparator"); + parent.appendChild(menusep); + } else { + const menuitem = doc.createXULElement("menuitem"); + applyItemAttributesToNode(item, menuitem); + + menuitem.addEventListener("command", () => { + item.click(); + }); + menuitem.addEventListener("DOMMenuItemActive", () => { + item.hover(); + }); + + parent.appendChild(menuitem); + } + }); +}; + +Menu.getMenuElementById = function (id, doc) { + const menuDoc = DevToolsUtils.getTopWindow(doc.defaultView).document; + return menuDoc.getElementById(id); +}; + +Menu.setApplicationMenu = () => { + throw Error("Not implemented"); +}; + +Menu.sendActionToFirstResponder = () => { + throw Error("Not implemented"); +}; + +Menu.buildFromTemplate = () => { + throw Error("Not implemented"); +}; + +function applyItemAttributesToNode(item, node) { + if (item.l10nID) { + node.ownerDocument.l10n.setAttributes(node, item.l10nID); + } else { + node.setAttribute("label", item.label); + if (item.accelerator) { + node.setAttribute("acceltext", item.accelerator); + } + if (item.accesskey) { + node.setAttribute("accesskey", item.accesskey); + } + } + if (item.type === "checkbox") { + node.setAttribute("type", "checkbox"); + } + if (item.type === "radio") { + node.setAttribute("type", "radio"); + } + if (item.disabled) { + node.setAttribute("disabled", "true"); + } + if (item.checked) { + node.setAttribute("checked", "true"); + } + if (item.image) { + node.setAttribute("image", item.image); + } + if (item.id) { + node.id = item.id; + } +} + +module.exports = Menu; diff --git a/devtools/client/framework/moz.build b/devtools/client/framework/moz.build new file mode 100644 index 0000000000..d1fe829b28 --- /dev/null +++ b/devtools/client/framework/moz.build @@ -0,0 +1,50 @@ +# -*- 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-telemetry-startup.toml", + "test/browser.toml", + "test/metrics/browser_metrics.toml", + "test/metrics/browser_metrics_debugger.toml", + "test/metrics/browser_metrics_inspector.toml", + "test/metrics/browser_metrics_netmonitor.toml", + "test/metrics/browser_metrics_webconsole.toml", +] +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] + +DIRS += [ + "actions", + "browser-toolbox", + "components", + "reducers", + "test/allocations", +] + +DevToolsModules( + "browser-menus.js", + "commands-from-url.js", + "devtools-browser.js", + "devtools.js", + "local-tab-commands-factory.js", + "menu-item.js", + "menu.js", + "selection.js", + "source-map-url-service.js", + "store-provider.js", + "store.js", + "toolbox-context-menu.js", + "toolbox-host-manager.js", + "toolbox-hosts.js", + "toolbox-options.js", + "toolbox-tabs-order-manager.js", + "toolbox-window.js", + "toolbox.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Framework") + +SPHINX_TREES["/devtools/tests/memory"] = "test/allocations/docs" diff --git a/devtools/client/framework/options-panel.css b/devtools/client/framework/options-panel.css new file mode 100644 index 0000000000..80f7dad383 --- /dev/null +++ b/devtools/client/framework/options-panel.css @@ -0,0 +1,203 @@ +/* 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/. */ +:root{ + user-select: none; +} + +.theme-light { + --experimental-background: #E0EEFF; + --experimental-color: #436286; +} + +.theme-dark { + --experimental-background: #436286; + --experimental-color: #E0EEFF; +} + +#options-panel-container { + overflow: auto; +} + +#options-panel { + display: block; +} + +.options-vertical-pane { + display: inline; + float: inline-start; +} + +.options-vertical-pane { + margin: 5px; + width: calc(100%/3 - 10px); + min-width: 320px; + padding-inline-start: 5px; + box-sizing: border-box; +} + +/* Snap to 50% width once there is not room for 3 columns anymore. + This prevents having 2 columns showing in a row, but taking up + only ~66% of the available space. */ +@media (max-width: 1000px) { + .options-vertical-pane { + width: calc(100%/2 - 10px); + } +} + +.options-vertical-pane fieldset { + border: none; + min-inline-size: auto; +} + +.options-vertical-pane fieldset legend { + font-size: 1.4rem; + margin-inline-start: -15px; + margin-bottom: 3px; + cursor: default; +} + +.options-vertical-pane fieldset + fieldset { + margin-top: 1rem; +} + +.options-groupbox { + margin-inline-start: 15px; + padding: 2px; +} + +.options-groupbox label { + display: flex; + padding: 4px 0; + align-items: center; + width: max-content; + max-width: 100%; +} + +/* Add padding for label of select inputs in order to + align it with surrounding checkboxes */ +.options-groupbox label span:first-child { + padding-inline-start: 5px; +} + +.options-groupbox label span + select { + margin-inline-start: 4px; +} + +.options-groupbox.horizontal-options-groupbox label { + display: inline-flex; + align-items: flex-end; +} + +.options-groupbox.horizontal-options-groupbox label + label { + margin-inline-start: 4px; +} + +.options-groupbox > * { + padding: 2px; +} + +.options-citation-label { + display: inline-block; + font-size: 1rem; + font-style: italic; + /* To align it with the checkbox */ + padding: 4px 0 0; + padding-inline-end: 4px; +} + +#devtools-sourceeditor-keybinding-select { + min-width: 130px; +} + +#devtools-sourceeditor-tabsize-select { + min-width: 80px; +} + +#screenshot-options legend::after { + content: ""; + display: inline-block; + background-image: url("chrome://devtools/skin/images/command-screenshot.svg"); + width: 16px; + height: 16px; + vertical-align: sub; + margin-inline-start: 5px; + -moz-context-properties: fill; + fill: var(--theme-toolbar-color); + opacity: 0.6; +} + +.deprecation-notice::before { + background-image: url("chrome://devtools/skin/images/alert.svg"); + content: ''; + display: inline-block; + flex-shrink: 0; + height: 15px; + margin-inline-end: 5px; + width: 15px; +} + +.deprecation-notice { + align-items: center; + background-color: var(--theme-warning-background); + color: var(--theme-warning-color); + display: flex; + margin-inline-start: 8px; + outline: var(--theme-warning-background) solid 4px; +} + +.deprecation-notice a { + color: currentColor; +} +.deprecation-notice a:hover{ + text-decoration: underline; +} + +.experimental-notice::before { + mask-image: url("chrome://devtools/skin/images/filter-small.svg"); + mask-size: 15px; + transform: scaleY(-1); + background-color: var(--experimental-color); + display: inline-block; + content: ""; + flex-shrink: 0; + height: 15px; + margin-inline-end: 5px; + width: 15px; +} + +.experimental-notice { + background-color: var(--experimental-background); + color: var(--experimental-color); + outline: var(--experimental-background) solid 4px; + align-items: center; + display: flex; + margin-inline-start: 8px; +} + +.experimental-notice a { + color: currentColor; +} +.experimental-notice a:hover{ + text-decoration: underline; +} + +@keyframes highlight { + 0% { + background-color: var(--theme-highlight-yellow); + } + 100% { + background-color: transparent; + } +} + +.options-panel-highlight { + animation: highlight 8s; + animation-timing-function: ease; +} + +@media (prefers-reduced-motion) { + .highlighted { + animation-timing-function: steps(1, end); + } +} diff --git a/devtools/client/framework/reducers/dom-mutation-breakpoints.js b/devtools/client/framework/reducers/dom-mutation-breakpoints.js new file mode 100644 index 0000000000..f5e9c63a2c --- /dev/null +++ b/devtools/client/framework/reducers/dom-mutation-breakpoints.js @@ -0,0 +1,134 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ +"use strict"; + +const initialReducerState = { + counter: 1, + breakpoints: [], +}; + +exports.reducer = domMutationBreakpointReducer; +function domMutationBreakpointReducer(state = initialReducerState, action) { + switch (action.type) { + case "ADD_DOM_MUTATION_BREAKPOINT": + const hasExistingBp = state.breakpoints.some( + bp => + bp.nodeFront === action.nodeFront && + bp.mutationType === action.mutationType + ); + + if (hasExistingBp) { + break; + } + + state = { + ...state, + counter: state.counter + 1, + breakpoints: [ + ...state.breakpoints, + { + id: `${state.counter}`, + nodeFront: action.nodeFront, + targetFront: action.nodeFront.targetFront, + mutationType: action.mutationType, + enabled: true, + }, + ], + }; + break; + case "REMOVE_DOM_MUTATION_BREAKPOINT": + for (const [index, bp] of state.breakpoints.entries()) { + if ( + bp.nodeFront === action.nodeFront && + bp.mutationType === action.mutationType + ) { + state = { + ...state, + breakpoints: [ + ...state.breakpoints.slice(0, index), + ...state.breakpoints.slice(index + 1), + ], + }; + break; + } + } + break; + case "REMOVE_DOM_MUTATION_BREAKPOINTS_FOR_FRONTS": { + const { nodeFronts } = action; + const nodeFrontSet = new Set(nodeFronts); + + const breakpoints = state.breakpoints.filter( + bp => !nodeFrontSet.has(bp.nodeFront) + ); + + // Since we might not have made any actual changes, we verify first + // to avoid unnecessary changes in the state. + if (state.breakpoints.length !== breakpoints.length) { + state = { + ...state, + breakpoints, + }; + } + break; + } + + case "REMOVE_TARGET": { + const { targetFront } = action; + // When a target is destroyed, remove breakpoints associated with it. + const breakpoints = state.breakpoints.filter( + bp => bp.targetFront !== targetFront + ); + + // Since we might not have made any actual changes, we verify first + // to avoid unnecessary changes in the state. + if (state.breakpoints.length !== breakpoints.length) { + state = { + ...state, + breakpoints, + }; + } + break; + } + + case "SET_DOM_MUTATION_BREAKPOINTS_ENABLED_STATE": { + const { enabledStates } = action; + const toUpdateById = new Map(enabledStates); + + const breakpoints = state.breakpoints.map(bp => { + const newBpState = toUpdateById.get(bp.id); + if (typeof newBpState === "boolean" && newBpState !== bp.enabled) { + bp = { + ...bp, + enabled: newBpState, + }; + } + + return bp; + }); + + // Since we might not have made any actual changes, we verify first + // to avoid unnecessary changes in the state. + if (state.breakpoints.some((bp, i) => breakpoints[i] !== bp)) { + state = { + ...state, + breakpoints, + }; + } + break; + } + } + return state; +} + +exports.getDOMMutationBreakpoints = getDOMMutationBreakpoints; +function getDOMMutationBreakpoints(state) { + return state.domMutationBreakpoints.breakpoints; +} + +exports.getDOMMutationBreakpoint = getDOMMutationBreakpoint; +function getDOMMutationBreakpoint(state, id) { + return ( + state.domMutationBreakpoints.breakpoints.find(v => v.id === id) || null + ); +} diff --git a/devtools/client/framework/reducers/index.js b/devtools/client/framework/reducers/index.js new file mode 100644 index 0000000000..3ab06e349c --- /dev/null +++ b/devtools/client/framework/reducers/index.js @@ -0,0 +1,10 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +module.exports = { + domMutationBreakpoints: + require("resource://devtools/client/framework/reducers/dom-mutation-breakpoints.js") + .reducer, +}; diff --git a/devtools/client/framework/reducers/moz.build b/devtools/client/framework/reducers/moz.build new file mode 100644 index 0000000000..e77a7cc2cc --- /dev/null +++ b/devtools/client/framework/reducers/moz.build @@ -0,0 +1,11 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "dom-mutation-breakpoints.js", + "index.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Framework") diff --git a/devtools/client/framework/selection.js b/devtools/client/framework/selection.js new file mode 100644 index 0000000000..a71bdf6b56 --- /dev/null +++ b/devtools/client/framework/selection.js @@ -0,0 +1,367 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +loader.lazyRequireGetter( + this, + "nodeConstants", + "resource://devtools/shared/dom-node-constants.js" +); + +/** + * Selection is a singleton belonging to the Toolbox that manages the current selected + * NodeFront. In addition, it provides some helpers about the context of the selected + * node. + * + * API + * + * new Selection() + * destroy() + * nodeFront (readonly) + * setNodeFront(node, origin="unknown") + * + * Helpers: + * + * window + * document + * isRoot() + * isNode() + * isHTMLNode() + * + * Check the nature of the node: + * + * isElementNode() + * isAttributeNode() + * isTextNode() + * isCDATANode() + * isEntityRefNode() + * isEntityNode() + * isProcessingInstructionNode() + * isCommentNode() + * isDocumentNode() + * isDocumentTypeNode() + * isDocumentFragmentNode() + * isNotationNode() + * + * Events: + * "new-node-front" when the inner node changed + * "attribute-changed" when an attribute is changed + * "detached-front" when the node (or one of its parents) is removed from + * the document + * "reparented" when the node (or one of its parents) is moved under + * a different node + */ +function Selection() { + EventEmitter.decorate(this); + + // The WalkerFront is dynamic and is always set to the selected NodeFront's WalkerFront. + this._walker = null; + // A single node front can be represented twice on the client when the node is a slotted + // element. It will be displayed once as a direct child of the host element, and once as + // a child of a slot in the "shadow DOM". The latter is called the slotted version. + this._isSlotted = false; + + this._onMutations = this._onMutations.bind(this); + this.setNodeFront = this.setNodeFront.bind(this); +} + +Selection.prototype = { + _onMutations(mutations) { + let attributeChange = false; + let pseudoChange = false; + let detached = false; + let parentNode = null; + + for (const m of mutations) { + if (!attributeChange && m.type == "attributes") { + attributeChange = true; + } + if (m.type == "childList") { + if (!detached && !this.isConnected()) { + if (this.isNode()) { + parentNode = m.target; + } + detached = true; + } + } + if (m.type == "pseudoClassLock") { + pseudoChange = true; + } + } + + // Fire our events depending on what changed in the mutations array + if (attributeChange) { + this.emit("attribute-changed"); + } + if (pseudoChange) { + this.emit("pseudoclass"); + } + if (detached) { + this.emit("detached-front", parentNode); + } + }, + + destroy() { + this.setWalker(); + this._nodeFront = null; + }, + + /** + * @param {WalkerFront|null} walker + */ + setWalker(walker = null) { + if (this._walker) { + this._removeWalkerFrontEventListeners(this._walker); + } + + this._walker = walker; + if (this._walker) { + this._setWalkerFrontEventListeners(this._walker); + } + }, + + /** + * Set event listeners on the passed walker front + * + * @param {WalkerFront} walker + */ + _setWalkerFrontEventListeners(walker) { + walker.on("mutations", this._onMutations); + }, + + /** + * Remove event listeners we previously set on walker front + * + * @param {WalkerFront} walker + */ + _removeWalkerFrontEventListeners(walker) { + walker.off("mutations", this._onMutations); + }, + + /** + * Called when a target front is destroyed. + * + * @param {TargetFront} front + * @emits detached-front + */ + onTargetDestroyed(targetFront) { + // if the current walker belongs to the target that is destroyed, emit a `detached-front` + // event so consumers can act accordingly (e.g. in the inspector, another node will be + // selected) + if ( + this._walker && + !targetFront.isTopLevel && + this._walker.targetFront == targetFront + ) { + this._removeWalkerFrontEventListeners(this._walker); + this.emit("detached-front"); + } + }, + + /** + * Update the currently selected node-front. + * + * @param {NodeFront} nodeFront + * The NodeFront being selected. + * @param {Object} (optional) + * - {String} reason: Reason that triggered the selection, will be fired with + * the "new-node-front" event. + * - {Boolean} isSlotted: Is the selection representing the slotted version of + * the node. + */ + setNodeFront(nodeFront, { reason = "unknown", isSlotted = false } = {}) { + this.reason = reason; + + // If an inlineTextChild text node is being set, then set it's parent instead. + const parentNode = nodeFront && nodeFront.parentNode(); + if (nodeFront && parentNode && parentNode.inlineTextChild === nodeFront) { + nodeFront = parentNode; + } + + if (this._nodeFront == null && nodeFront == null) { + // Avoid to notify multiple "unselected" events with a null/undefined nodeFront + // (e.g. once when the webpage start to navigate away from the current webpage, + // and then again while the new page is being loaded). + return; + } + + this.emit("node-front-will-unset"); + + this._isSlotted = isSlotted; + this._nodeFront = nodeFront; + + if (nodeFront) { + this.setWalker(nodeFront.walkerFront); + } else { + this.setWalker(); + } + + this.emit("new-node-front", nodeFront, this.reason); + }, + + get nodeFront() { + return this._nodeFront; + }, + + isRoot() { + return ( + this.isNode() && this.isConnected() && this._nodeFront.isDocumentElement + ); + }, + + isNode() { + return !!this._nodeFront; + }, + + isConnected() { + let node = this._nodeFront; + if (!node || node.isDestroyed()) { + return false; + } + + while (node) { + if (node === this._walker.rootNode) { + return true; + } + node = node.parentOrHost(); + } + return false; + }, + + isHTMLNode() { + const xhtmlNs = "http://www.w3.org/1999/xhtml"; + return this.isNode() && this.nodeFront.namespaceURI == xhtmlNs; + }, + + isSVGNode() { + const svgNs = "http://www.w3.org/2000/svg"; + return this.isNode() && this.nodeFront.namespaceURI == svgNs; + }, + + isMathMLNode() { + const mathmlNs = "http://www.w3.org/1998/Math/MathML"; + return this.isNode() && this.nodeFront.namespaceURI == mathmlNs; + }, + + // Node type + + isElementNode() { + return ( + this.isNode() && this.nodeFront.nodeType == nodeConstants.ELEMENT_NODE + ); + }, + + isPseudoElementNode() { + return this.isNode() && this.nodeFront.isPseudoElement; + }, + + isAnonymousNode() { + return this.isNode() && this.nodeFront.isAnonymous; + }, + + isAttributeNode() { + return ( + this.isNode() && this.nodeFront.nodeType == nodeConstants.ATTRIBUTE_NODE + ); + }, + + isTextNode() { + return this.isNode() && this.nodeFront.nodeType == nodeConstants.TEXT_NODE; + }, + + isCDATANode() { + return ( + this.isNode() && + this.nodeFront.nodeType == nodeConstants.CDATA_SECTION_NODE + ); + }, + + isEntityRefNode() { + return ( + this.isNode() && + this.nodeFront.nodeType == nodeConstants.ENTITY_REFERENCE_NODE + ); + }, + + isEntityNode() { + return ( + this.isNode() && this.nodeFront.nodeType == nodeConstants.ENTITY_NODE + ); + }, + + isProcessingInstructionNode() { + return ( + this.isNode() && + this.nodeFront.nodeType == nodeConstants.PROCESSING_INSTRUCTION_NODE + ); + }, + + isCommentNode() { + return ( + this.isNode() && + this.nodeFront.nodeType == nodeConstants.PROCESSING_INSTRUCTION_NODE + ); + }, + + isDocumentNode() { + return ( + this.isNode() && this.nodeFront.nodeType == nodeConstants.DOCUMENT_NODE + ); + }, + + /** + * @returns true if the selection is the <body> HTML element. + */ + isBodyNode() { + return ( + this.isHTMLNode() && + this.isConnected() && + this.nodeFront.nodeName === "BODY" + ); + }, + + /** + * @returns true if the selection is the <head> HTML element. + */ + isHeadNode() { + return ( + this.isHTMLNode() && + this.isConnected() && + this.nodeFront.nodeName === "HEAD" + ); + }, + + isDocumentTypeNode() { + return ( + this.isNode() && + this.nodeFront.nodeType == nodeConstants.DOCUMENT_TYPE_NODE + ); + }, + + isDocumentFragmentNode() { + return ( + this.isNode() && + this.nodeFront.nodeType == nodeConstants.DOCUMENT_FRAGMENT_NODE + ); + }, + + isNotationNode() { + return ( + this.isNode() && this.nodeFront.nodeType == nodeConstants.NOTATION_NODE + ); + }, + + isSlotted() { + return this._isSlotted; + }, + + isShadowRootNode() { + return this.isNode() && this.nodeFront.isShadowRoot; + }, +}; + +module.exports = Selection; diff --git a/devtools/client/framework/source-map-url-service.js b/devtools/client/framework/source-map-url-service.js new file mode 100644 index 0000000000..8e08e9e4cb --- /dev/null +++ b/devtools/client/framework/source-map-url-service.js @@ -0,0 +1,501 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const SOURCE_MAP_PREF = "devtools.source-map.client-service.enabled"; + +/** + * A simple service to track source actors and keep a mapping between + * original URLs and objects holding the source or style actor's ID + * (which is used as a cookie by the devtools-source-map service) and + * the source map URL. + * + * @param {object} commands + * The commands object with all interfaces defined from devtools/shared/commands/ + * @param {SourceMapLoader} sourceMapLoader + * The source-map-loader implemented in devtools/client/shared/source-map-loader/ + */ +class SourceMapURLService { + constructor(commands, sourceMapLoader) { + this._commands = commands; + this._sourceMapLoader = sourceMapLoader; + + this._prefValue = Services.prefs.getBoolPref(SOURCE_MAP_PREF); + this._pendingIDSubscriptions = new Map(); + this._pendingURLSubscriptions = new Map(); + this._urlToIDMap = new Map(); + this._mapsById = new Map(); + this._sourcesLoading = null; + this._onResourceAvailable = this._onResourceAvailable.bind(this); + this._runningCallback = false; + + this._syncPrevValue = this._syncPrevValue.bind(this); + this._clearAllState = this._clearAllState.bind(this); + + Services.prefs.addObserver(SOURCE_MAP_PREF, this._syncPrevValue); + + // If a tool has changed or introduced a source map + // (e.g, by pretty-printing a source), tell the + // source map URL service about the change, so that + // subscribers to that service can be updated as + // well. + this._sourceMapLoader.on( + "source-map-created", + this.newSourceMapCreated.bind(this) + ); + } + + destroy() { + Services.prefs.removeObserver(SOURCE_MAP_PREF, this._syncPrevValue); + + this._clearAllState(); + + const { resourceCommand } = this._commands; + try { + resourceCommand.unwatchResources( + [ + resourceCommand.TYPES.STYLESHEET, + resourceCommand.TYPES.SOURCE, + resourceCommand.TYPES.DOCUMENT_EVENT, + ], + { onAvailable: this._onResourceAvailable } + ); + } catch (e) { + // If unwatchResources is called before finishing process of watchResources, + // it throws an error during stopping listener. + } + + this._sourcesLoading = null; + this._pendingIDSubscriptions = null; + this._pendingURLSubscriptions = null; + this._urlToIDMap = null; + this._mapsById = null; + } + + /** + * Subscribe to notifications about the original location of a given + * generated location, as it may not be known at this time, may become + * available at some unknown time in the future, or may change from one + * location to another. + * + * @param {string} id The actor ID of the source. + * @param {number} line The line number in the source. + * @param {number} column The column number in the source. + * @param {Function} callback A callback that may eventually be passed an + * an object with url/line/column properties specifying a location in + * the original file, or null if no particular original location could + * be found. The callback will run synchronously if the location is + * already know to the URL service. + * + * @return {Function} A function to call to remove this subscription. The + * "callback" argument is guaranteed to never run once unsubscribed. + */ + subscribeByID(id, line, column, callback) { + this._ensureAllSourcesPopulated(); + + let pending = this._pendingIDSubscriptions.get(id); + if (!pending) { + pending = new Set(); + this._pendingIDSubscriptions.set(id, pending); + } + const entry = { + line, + column, + callback, + unsubscribed: false, + owner: pending, + }; + pending.add(entry); + + const map = this._mapsById.get(id); + if (map) { + this._flushPendingIDSubscriptionsToMapQueries(map); + } + + return () => { + entry.unsubscribed = true; + entry.owner.delete(entry); + }; + } + + /** + * Subscribe to notifications about the original location of a given + * generated location, as it may not be known at this time, may become + * available at some unknown time in the future, or may change from one + * location to another. + * + * @param {string} id The actor ID of the source. + * @param {number} line The line number in the source. + * @param {number} column The column number in the source. + * @param {Function} callback A callback that may eventually be passed an + * an object with url/line/column properties specifying a location in + * the original file, or null if no particular original location could + * be found. The callback will run synchronously if the location is + * already know to the URL service. + * + * @return {Function} A function to call to remove this subscription. The + * "callback" argument is guaranteed to never run once unsubscribed. + */ + subscribeByURL(url, line, column, callback) { + this._ensureAllSourcesPopulated(); + + let pending = this._pendingURLSubscriptions.get(url); + if (!pending) { + pending = new Set(); + this._pendingURLSubscriptions.set(url, pending); + } + const entry = { + line, + column, + callback, + unsubscribed: false, + owner: pending, + }; + pending.add(entry); + + const id = this._urlToIDMap.get(url); + if (id) { + this._convertPendingURLSubscriptionsToID(url, id); + const map = this._mapsById.get(id); + if (map) { + this._flushPendingIDSubscriptionsToMapQueries(map); + } + } + + return () => { + entry.unsubscribed = true; + entry.owner.delete(entry); + }; + } + + /** + * Subscribe generically based on either an ID or a URL. + * + * In an ideal world we'd always know which of these to use, but there are + * still cases where end up with a mixture of both, so this is provided as + * a helper. If you can specifically use one of these, please do that + * instead however. + */ + subscribeByLocation({ id, url, line, column }, callback) { + if (id) { + return this.subscribeByID(id, line, column, callback); + } + + return this.subscribeByURL(url, line, column, callback); + } + + /** + * Tell the URL service than some external entity has registered a sourcemap + * in the worker for one of the source files. + * + * @param {Array<string>} ids The actor ids of the sources that had the map registered. + */ + async newSourceMapCreated(ids) { + await this._ensureAllSourcesPopulated(); + + for (const id of ids) { + const map = this._mapsById.get(id); + if (!map) { + // State could have been cleared. + continue; + } + + map.loaded = Promise.resolve(); + for (const query of map.queries.values()) { + query.action = null; + query.result = null; + if (this._prefValue) { + this._dispatchQuery(query); + } + } + } + } + + _syncPrevValue() { + this._prefValue = Services.prefs.getBoolPref(SOURCE_MAP_PREF); + + for (const map of this._mapsById.values()) { + for (const query of map.queries.values()) { + this._ensureSubscribersSynchronized(query); + } + } + } + + _clearAllState() { + this._sourceMapLoader.clearSourceMaps(); + this._pendingIDSubscriptions.clear(); + this._pendingURLSubscriptions.clear(); + this._urlToIDMap.clear(); + this._mapsById.clear(); + } + + _onNewJavascript(source) { + const { url, actor: id, sourceMapBaseURL, sourceMapURL } = source; + + this._onNewSource(id, url, sourceMapURL, sourceMapBaseURL); + } + + _onNewStyleSheet(sheet) { + const { + href, + nodeHref, + sourceMapBaseURL, + sourceMapURL, + resourceId: id, + } = sheet; + const url = href || nodeHref; + + this._onNewSource(id, url, sourceMapURL, sourceMapBaseURL); + } + + _onNewSource(id, url, sourceMapURL, sourceMapBaseURL) { + this._urlToIDMap.set(url, id); + this._convertPendingURLSubscriptionsToID(url, id); + + let map = this._mapsById.get(id); + if (!map) { + map = { + id, + url, + sourceMapURL, + sourceMapBaseURL, + loaded: null, + queries: new Map(), + }; + this._mapsById.set(id, map); + } else if ( + map.id !== id && + map.url !== url && + map.sourceMapURL !== sourceMapURL && + map.sourceMapBaseURL !== sourceMapBaseURL + ) { + console.warn( + `Attempted to load populate sourcemap for source ${id} multiple times` + ); + } + + this._flushPendingIDSubscriptionsToMapQueries(map); + } + + _buildQuery(map, line, column) { + const key = `${line}:${column}`; + let query = map.queries.get(key); + if (!query) { + query = { + map, + line, + column, + subscribers: new Set(), + action: null, + result: null, + mostRecentEmitted: null, + }; + map.queries.set(key, query); + } + return query; + } + + _dispatchQuery(query, newSubscribers = null) { + if (!this._prefValue) { + throw new Error("This function should only be called if the pref is on."); + } + + if (!query.action) { + const { map } = query; + + // Call getOriginalURLs to make sure the source map has been + // fetched. We don't actually need the result of this though. + if (!map.loaded) { + map.loaded = this._sourceMapLoader.getOriginalURLs({ + id: map.id, + url: map.url, + sourceMapBaseURL: map.sourceMapBaseURL, + sourceMapURL: map.sourceMapURL, + }); + } + + const action = (async () => { + let result = null; + try { + await map.loaded; + } catch (e) { + // SourceMapLoader.getOriginalURLs may throw, but it will handle + // the exception and notify the user via a console message. + // So ignore the exception here, which is meant to be used by the Debugger. + } + + try { + const position = await this._sourceMapLoader.getOriginalLocation({ + sourceId: map.id, + line: query.line, + column: query.column, + }); + if (position && position.sourceId !== map.id) { + result = { + url: position.sourceUrl, + line: position.line, + column: position.column, + }; + } + } finally { + // If this action was dispatched and then the file was pretty-printed + // we want to ignore the result since the query has restarted. + if (action === query.action) { + // It is important that we consistently set the query result and + // trigger the subscribers here in order to maintain the invariant + // that if 'result' is truthy, then the subscribers will have run. + const position = result; + query.result = { position }; + this._ensureSubscribersSynchronized(query); + } + } + })(); + query.action = action; + } + + this._ensureSubscribersSynchronized(query); + } + + _ensureSubscribersSynchronized(query) { + // Synchronize the subscribers with the pref-disabled state if they need it. + if (!this._prefValue) { + if (query.mostRecentEmitted) { + query.mostRecentEmitted = null; + this._dispatchSubscribers(null, query.subscribers); + } + return; + } + + // Synchronize the subscribers with the newest computed result if they + // need it. + const { result } = query; + if (result && query.mostRecentEmitted !== result.position) { + query.mostRecentEmitted = result.position; + this._dispatchSubscribers(result.position, query.subscribers); + } + } + + _dispatchSubscribers(position, subscribers) { + // We copy the subscribers before iterating because something could be + // removed while we're calling the callbacks, which is also why we check + // the 'unsubscribed' flag. + for (const subscriber of Array.from(subscribers)) { + if (subscriber.unsubscribed) { + continue; + } + + if (this._runningCallback) { + console.error( + "The source map url service does not support reentrant subscribers." + ); + continue; + } + + try { + this._runningCallback = true; + + const { callback } = subscriber; + callback(position ? { ...position } : null); + } catch (err) { + console.error("Error in source map url service subscriber", err); + } finally { + this._runningCallback = false; + } + } + } + + _flushPendingIDSubscriptionsToMapQueries(map) { + const subscriptions = this._pendingIDSubscriptions.get(map.id); + if (!subscriptions || subscriptions.size === 0) { + return; + } + this._pendingIDSubscriptions.delete(map.id); + + for (const entry of subscriptions) { + const query = this._buildQuery(map, entry.line, entry.column); + + const { subscribers } = query; + + entry.owner = subscribers; + subscribers.add(entry); + + if (query.mostRecentEmitted) { + // Maintain the invariant that if a query has emitted a value, then + // _all_ subscribers will have received that value. + this._dispatchSubscribers(query.mostRecentEmitted, [entry]); + } + + if (this._prefValue) { + this._dispatchQuery(query); + } + } + } + + _ensureAllSourcesPopulated() { + if (!this._prefValue || this._commands.descriptorFront.isWorkerDescriptor) { + return null; + } + + if (!this._sourcesLoading) { + const { resourceCommand } = this._commands; + const { STYLESHEET, SOURCE, DOCUMENT_EVENT } = resourceCommand.TYPES; + + const onResources = resourceCommand.watchResources( + [STYLESHEET, SOURCE, DOCUMENT_EVENT], + { + onAvailable: this._onResourceAvailable, + } + ); + this._sourcesLoading = onResources; + } + + return this._sourcesLoading; + } + + waitForSourcesLoading() { + if (this._sourcesLoading) { + return this._sourcesLoading; + } + return Promise.resolve(); + } + + _onResourceAvailable(resources) { + const { resourceCommand } = this._commands; + const { STYLESHEET, SOURCE, DOCUMENT_EVENT } = resourceCommand.TYPES; + for (const resource of resources) { + // Only consider top level document, and ignore remote iframes top document + if ( + resource.resourceType == DOCUMENT_EVENT && + resource.name == "will-navigate" && + resource.targetFront.isTopLevel + ) { + this._clearAllState(); + } else if (resource.resourceType == STYLESHEET) { + this._onNewStyleSheet(resource); + } else if (resource.resourceType == SOURCE) { + this._onNewJavascript(resource); + } + } + } + + _convertPendingURLSubscriptionsToID(url, id) { + const urlSubscriptions = this._pendingURLSubscriptions.get(url); + if (!urlSubscriptions) { + return; + } + this._pendingURLSubscriptions.delete(url); + + let pending = this._pendingIDSubscriptions.get(id); + if (!pending) { + pending = new Set(); + this._pendingIDSubscriptions.set(id, pending); + } + for (const entry of urlSubscriptions) { + entry.owner = pending; + pending.add(entry); + } + } +} + +exports.SourceMapURLService = SourceMapURLService; diff --git a/devtools/client/framework/store-provider.js b/devtools/client/framework/store-provider.js new file mode 100644 index 0000000000..51a37b20f4 --- /dev/null +++ b/devtools/client/framework/store-provider.js @@ -0,0 +1,10 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + createProvider, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +module.exports = createProvider("toolbox-store"); diff --git a/devtools/client/framework/store.js b/devtools/client/framework/store.js new file mode 100644 index 0000000000..53556de887 --- /dev/null +++ b/devtools/client/framework/store.js @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const createStore = require("resource://devtools/client/shared/redux/create-store.js"); +const reducers = require("resource://devtools/client/framework/reducers/index.js"); + +exports.createToolboxStore = () => + createStore(reducers, { + // Uncomment this for logging in tests. + // shouldLog: true, + }); diff --git a/devtools/client/framework/test/allocations/browser_allocations_browser_console.js b/devtools/client/framework/test/allocations/browser_allocations_browser_console.js new file mode 100644 index 0000000000..13d0171dfa --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_browser_console.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while opening and closing the Browser Console + +const TEST_URL = + "http://example.com/browser/devtools/client/framework/test/allocations/reloaded-page.html"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const { + BrowserConsoleManager, +} = require("resource://devtools/client/webconsole/browser-console-manager.js"); + +async function testScript() { + // Open + await BrowserConsoleManager.toggleBrowserConsole(); + + // Reload the tab to make the test slightly more real + const hud = BrowserConsoleManager.getBrowserConsole(); + const onTargetProcessed = hud.commands.targetCommand.once( + "processed-available-target" + ); + + gBrowser.reloadTab(gBrowser.selectedTab); + + info("Wait for target of the new document to be fully processed"); + await onTargetProcessed; + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Close + await BrowserConsoleManager.toggleBrowserConsole(); + + // Browser console still cleanup stuff after the resolution of toggleBrowserConsole. + // So wait for a little while to ensure it completes all cleanups. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); +} + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["devtools.browsertoolbox.scope", "everything"]], + }); + + const tab = await addTab(TEST_URL); + + // Run the test scenario first before recording in order to load all the + // modules. Otherwise they get reported as "still allocated" objects, + // whereas we do expect them to be kept in memory as they are loaded via + // the main DevTools loader, which keeps the module loaded until the + // shutdown of Firefox + await testScript(); + + // Now, run the test script. This time, we record this run. + await startRecordingAllocations(); + + for (let i = 0; i < 3; i++) { + await testScript(); + } + + await stopRecordingAllocations("browser-console"); + + gBrowser.removeTab(tab); +}); diff --git a/devtools/client/framework/test/allocations/browser_allocations_browser_console.toml b/devtools/client/framework/test/allocations/browser_allocations_browser_console.toml new file mode 100644 index 0000000000..785f665b85 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_browser_console.toml @@ -0,0 +1,17 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_browser_console.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_debugger.js b/devtools/client/framework/test/allocations/browser_allocations_reload_debugger.js new file mode 100644 index 0000000000..748a5e906e --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_debugger.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while reloading the page with the debugger opened + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/framework/test/allocations/reload-test.js", + this +); + +add_task(createPanelReloadTest("reload-debugger", "jsdebugger")); diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_debugger.toml b/devtools/client/framework/test/allocations/browser_allocations_reload_debugger.toml new file mode 100644 index 0000000000..aabb21dc1a --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_debugger.toml @@ -0,0 +1,20 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", + "reload-test.js", + "reloaded-page.html", + "reloaded.png", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_reload_debugger.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_inspector.js b/devtools/client/framework/test/allocations/browser_allocations_reload_inspector.js new file mode 100644 index 0000000000..3369c54f24 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_inspector.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while reloading the page with the inspector opened + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/framework/test/allocations/reload-test.js", + this +); + +add_task(createPanelReloadTest("reload-inspector", "inspector")); diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_inspector.toml b/devtools/client/framework/test/allocations/browser_allocations_reload_inspector.toml new file mode 100644 index 0000000000..f2046ea621 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_inspector.toml @@ -0,0 +1,20 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", + "reload-test.js", + "reloaded-page.html", + "reloaded.png", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_reload_inspector.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_netmonitor.js b/devtools/client/framework/test/allocations/browser_allocations_reload_netmonitor.js new file mode 100644 index 0000000000..2a57652ac5 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_netmonitor.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while reloading the page with the netmonitor opened + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/framework/test/allocations/reload-test.js", + this +); + +add_task(createPanelReloadTest("reload-netmonitor", "netmonitor")); diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_netmonitor.toml b/devtools/client/framework/test/allocations/browser_allocations_reload_netmonitor.toml new file mode 100644 index 0000000000..3a4a0b8464 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_netmonitor.toml @@ -0,0 +1,20 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", + "reload-test.js", + "reloaded-page.html", + "reloaded.png", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_reload_netmonitor.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_no_devtools.js b/devtools/client/framework/test/allocations/browser_allocations_reload_no_devtools.js new file mode 100644 index 0000000000..e2f344fcb5 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_no_devtools.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while reloading the page without anything related to DevTools running + +const TEST_URL = + "http://example.com/browser/devtools/client/framework/test/allocations/reloaded-page.html"; + +async function testScript() { + await BrowserTestUtils.reloadTab(gBrowser.selectedTab); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); +} + +add_task(async function () { + const tab = await addTab(TEST_URL); + + // Run the test scenario first before recording in order to load all the + // modules. Otherwise they get reported as "still allocated" objects, + // whereas we do expect them to be kept in memory as they are loaded via + // the main DevTools loader, which keeps the module loaded until the + // shutdown of Firefox + await testScript(); + + await startRecordingAllocations({ + alsoRecordContentProcess: true, + }); + + // Now, run the test script. This time, we record this run. + for (let i = 0; i < 10; i++) { + await testScript(); + } + + await stopRecordingAllocations("reload-no-devtools", { + alsoRecordContentProcess: true, + }); + + gBrowser.removeTab(tab); +}); diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_no_devtools.toml b/devtools/client/framework/test/allocations/browser_allocations_reload_no_devtools.toml new file mode 100644 index 0000000000..58dfadc598 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_no_devtools.toml @@ -0,0 +1,19 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", + "reloaded-page.html", + "reloaded.png", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_reload_no_devtools.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_webconsole.js b/devtools/client/framework/test/allocations/browser_allocations_reload_webconsole.js new file mode 100644 index 0000000000..a60fd03b3c --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_webconsole.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while reloading the page with the webconsole opened + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/framework/test/allocations/reload-test.js", + this +); + +add_task(createPanelReloadTest("reload-webconsole", "webconsole")); diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_webconsole.toml b/devtools/client/framework/test/allocations/browser_allocations_reload_webconsole.toml new file mode 100644 index 0000000000..58bb9b733e --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_webconsole.toml @@ -0,0 +1,20 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", + "reload-test.js", + "reloaded-page.html", + "reloaded.png", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_reload_webconsole.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/browser_allocations_target.js b/devtools/client/framework/test/allocations/browser_allocations_target.js new file mode 100644 index 0000000000..a93d6b51c9 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_target.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while spawning Commands and the first top level target + +const TEST_URL = + "data:text/html;charset=UTF-8,<div>Target allocations test</div>"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const { + CommandsFactory, +} = require("resource://devtools/shared/commands/commands-factory.js"); + +async function testScript(tab) { + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + + // destroy the commands to also destroy the dedicated client. + await commands.destroy(); + + // Spin the event loop to ensure commands destruction is fully completed + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 0)); +} + +add_task(async function () { + const tab = await addTab(TEST_URL); + + // Run the test scenario first before recording in order to load all the + // modules. Otherwise they get reported as "still allocated" objects, + // whereas we do expect them to be kept in memory as they are loaded via + // the main DevTools loader, which keeps the module loaded until the + // shutdown of Firefox + await testScript(tab); + + await startRecordingAllocations(); + + // Now, run the test script. This time, we record this run. + await testScript(tab); + + await stopRecordingAllocations("target"); + + gBrowser.removeTab(tab); +}); diff --git a/devtools/client/framework/test/allocations/browser_allocations_target.toml b/devtools/client/framework/test/allocations/browser_allocations_target.toml new file mode 100644 index 0000000000..d5b4b158d5 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_target.toml @@ -0,0 +1,17 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_target.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/browser_allocations_toolbox.js b/devtools/client/framework/test/allocations/browser_allocations_toolbox.js new file mode 100644 index 0000000000..e0f86511bc --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_toolbox.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while opening and closing DevTools + +const TEST_URL = + "data:text/html;charset=UTF-8,<div>Target allocations test</div>"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const { + gDevTools, +} = require("resource://devtools/client/framework/devtools.js"); + +async function testScript(tab) { + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "inspector", + }); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + + await toolbox.destroy(); + + // Spin the event loop to ensure toolbox destroy is fully completed + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 0)); +} + +add_task(async function () { + const tab = await addTab(TEST_URL); + + // Run the test scenario first before recording in order to load all the + // modules. Otherwise they get reported as "still allocated" objects, + // whereas we do expect them to be kept in memory as they are loaded via + // the main DevTools loader, which keeps the module loaded until the + // shutdown of Firefox + await testScript(tab); + + await startRecordingAllocations(); + + // Now, run the test script. This time, we record this run. + for (let i = 0; i < 3; i++) { + await testScript(tab); + } + + await stopRecordingAllocations("toolbox"); + + gBrowser.removeTab(tab); +}); diff --git a/devtools/client/framework/test/allocations/browser_allocations_toolbox.toml b/devtools/client/framework/test/allocations/browser_allocations_toolbox.toml new file mode 100644 index 0000000000..bbeeb14e45 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_toolbox.toml @@ -0,0 +1,17 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_toolbox.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/docs/index.md b/devtools/client/framework/test/allocations/docs/index.md new file mode 100644 index 0000000000..f0d6921325 --- /dev/null +++ b/devtools/client/framework/test/allocations/docs/index.md @@ -0,0 +1,241 @@ +# Allocation tests + +The [allocations](https://searchfox.org/mozilla-central/source/devtools/client/framework/test/allocations) folder contains special mochitests which are meant to record data about the memory usage of DevTools. +This uses Spidermonkey's Memory API implemented next to the debugger API. +For more info, see the following doc: +<https://searchfox.org/mozilla-central/source/js/src/doc/Debugger/Debugger.Memory.md> + +# Test example + +```javascript +add_task(async function() { + // Execute preliminary setup in order to be able to run your scenario + // You would typicaly load modules, open a tab, a toolbox, ... + ... + + // Run the test scenario first before recording in order to load all the + // modules. Otherwise they get reported as "still allocated" objects, + // whereas we do expect them to be kept in memory as they are loaded via + // the main DevTools loader, which keeps the module loaded until the + // shutdown of Firefox + await testScript(); + + // Pass alsoRecordContentProcess if you want to record the content process + // of the current tab. Otherwise it will only record parent process objects. + await startRecordingAllocations({ alsoRecordContentProcess: true }); + + // Now, run the test script. This time, we record this run. + await testScript(toolbox); + + // This will stop the record and also publish the results to Talos database + // Second argument will be the name of the test displayed in Talos. + // Many tests will be recorded, but all of them will be prefixed with this string. + await stopRecordingAllocations("reload", { alsoRecordContentProcess: true }); + + // Then, here you can execute cleanup. + // You would typically close the tab, toolbox, ... +}); +``` + +# How to run them locally + +```bash +$ ./mach mochitest --headless devtools/client/framework/test/allocations/ +``` + +And to only see the results: +```bash +$ ./mach mochitest --headless devtools/client/framework/test/allocations/ | grep " test leaked " +``` + +# Debug leaks + +If you are seeing a regression or an improvement, only seeing the number of objects being leaked isn't super helpful. +The tests includes some special debug modes which are printing lots of data to figure out what is leaking and why. + +You may run the test with the following env variable to turn debug mode on: +```bash +DEBUG_DEVTOOLS_ALLOCATIONS=leak|allocations $ ./mach mochitest --headless devtools/client/framework/test/allocations/the-fault-test.js +``` + +**DEBUG_DEVTOOLS_ALLOCATIONS** can enable two distinct debug output. (Only one can be enabled at a given time) + +**DEBUG_DEVTOOLS_ALLOCATIONS=allocations** will report all allocation sites that have been made +while running your test. This will include allocations which has been freed. +This view is especially useful if you want to reduce allocations in order to reduce GC overload. + +**DEBUG_DEVTOOLS_ALLOCATIONS=leak** will report only the allocations which are still allocated +at the end of your test. Sometimes it will only report allocations with missing stack trace. +Thus making the preview view helpful. + +## Example + +Let's assume we have the following code: + +```javascript + 1: exports.MyModule = { + 2: globalArray: [], + 3: test() { + 3: // The following object will be allocated but not leaked, + 5: // as we keep no reference to it anywhere + 6: const transientObject = {}; + 7: + 8: // The following object will be allocated on this line, + 9: // but leaked on the following one. By storing a reference +10: // to it in the global array which is never cleared. +11: const leakedObject = {}; +12: this.globalArray.push(leakedObject); +13: }, +14: }; +``` + +And that, we have a memory test doing this: + +```javascript + const { MyModule } = require("devtools/my-module"); + + await startRecordingAllocations(); + + MyModule.test(); + + await stopRecordingAllocations("target"); +``` + +We can first review all the allocations by running: + +```bash +DEBUG_DEVTOOLS_ALLOCATIONS=allocations $ ./mach mochitest --headless devtools/client/framework/test/allocations/browser_allocation_myTest.js + +``` + +which will print at the end: + +```javascript +DEVTOOLS ALLOCATION: all allocations (which may be freed or are still allocated): +[ + { + "src": "UNKNOWN", + "count": 80, + "lines": [ + "?: 80" + ] + }, + { + "src": "resource://devtools/my-module.js", + "count": 2, + "lines": [ + "11: 1" + "6: 1" + ] + } +] +``` + +The first part, with `UNKNOWN` can be ignored. This is about objects with missing allocation sites. +The second part of this logs tells us that 2 objects were allocated from my-module.js when running the test. +One has been allocated at line 6, it is `transcientObject`. +Another one has been allocated at line 11, it is `leakedObject`. + +Now, we can use the second view to focus only on objects that have been kept allocated: + +```bash +DEBUG_DEVTOOLS_ALLOCATIONS=leaks $ ./mach mochitest --headless devtools/client/framework/test/allocations/browser_allocation_myTest.js + +``` + +which will print at the end: + +```javascript +DEVTOOLS ALLOCATION: allocations which leaked: +[ + { + "src": "UNKNOWN", + "count": 80, + "lines": [ + "?: 80" + ] + }, + { + "src": "resource://devtools/shared/commands/commands-factory.js", + "count": 1, + "lines": [ + "11: 1" + ] + } +] +``` + +Similarly, we can focus only on the second part, which tells us that only one object is being leaked +and this object has been originally created from line 11, this is `leakedObject`. +This doesn't tell us why the object is being kept allocated, but at least we know which one is being kept in memory. + + +## Debug leaks via dominators + +This last feature might be the most powerful and isn't bound to DEBUG_DEVTOOLS_ALLOCATIONS. +This is always enabled. +Also, it requires to know which particular object is being leaked and also require to hack +the codebase in order to pass a reference of the suspicious object to the test helper. + +You can instruct the test helper to track a given object by doing this: + +```javascript + 1: // Let's say it is some code running from "my-module.js" + 2: + 3: // From a DevTools CommonJS module: + 4: const { track } = require("devtools/shared/test-helpers/tracked-objects.sys.mjs"); + 5: // From anything else, JSM, XPCOM module,...: + 6: const { track } = ChromeUtils.importESModule("resource://devtools/shared/test-helpers/tracked-objects.sys.mjs"); + 7: + 8: const g = []; + 9: function someFunctionInDevToolsCalledBySomething() { +10: const myLeakedObject = {}; +11: track(myLeakedObject); +12: +13: // Simulate a leak by holding a reference to the object in a global `g` array +14: g.push({ seeMyCustomAttributeHere: myLeakedObject }); +15: } +``` + +Then, when running the test you will get such output: + +```bash + 0:41.26 GECKO(644653) # Tracing: Object@my-module:10 + 0:40.65 GECKO(644653) ### Path(s) from root: + 0:41.26 GECKO(644653) - other@no-stack:undefined.WeakMap entry value + 0:41.26 GECKO(644653) \--> LexicalEnvironment@base-loader.sys.mjs:160.**UNKNOWN SLOT 1** + 0:41.26 GECKO(644653) \--> Object@base-loader.sys.mjs:155.g + 0:41.26 GECKO(644653) \--> Array@my-module.js:8.objectElements[0] + 0:41.26 GECKO(644653) \--> Object@my-module.js:14.seeMyCustomAttributeHere + 0:41.26 GECKO(644653) \--> Object@my-module.js:10 +``` + +This output means that `myLeakedObject` was originally allocated from my-module.js at line 10. +And is being held allocated because it is kept in an Object allocated from my-module.js at line 14. +This is our custom object we stored in `g` global Array. +This custom object it hold by the Array allocated at line 8 of my-module.js. +And this array is held allocated from an Object, itself allocated by base-loader.sys.mjs at line 155. +This is the global of the my-module.js's module, created by DevTools loader. +Then we see some more low level object up to another global object, which misses its allocation site. + +# How to easily get data from try run + +```bash +$ ./mach try fuzzy devtools/client/framework/test/allocations/ --query "'linux 'chrome-e10s 'opt '64-qr/opt" +``` + +You might also pass `--rebuild 3` if the test result is having some noise and you want more test runs. + +# Following trends for these tests + +You may try looking at: +<https://firefox-dev.tools/performance-dashboard/tools/memory.html> + +Or at: +<https://treeherder.mozilla.org/perfherder/graphs?highlightAlerts=1&highlightChangelogData=1&series=autoland,3887143,1,12&series=mozilla-central,3887737,1,12&series=mozilla-central,3887740,1,12&series=mozilla-central,3887743,1,12&series=mozilla-central,3896204,1,12&timerange=2592000&zoom=1630504360002,1632239562424,0,123469.11111111111> + +Link that you get from: <https://treeherder.mozilla.org/perfherder/graphs> +by looking at last year data for "DevTools" in the first dropdown, +and double clicking on the relevant line in "Tests" menulist. + +Significant improvements and regressions will be notified through [the following dashboard](https://treeherder.mozilla.org/perfherder/alerts?hideDwnToInv=1&page=1&framework=12). diff --git a/devtools/client/framework/test/allocations/head.js b/devtools/client/framework/test/allocations/head.js new file mode 100644 index 0000000000..a7e7f56a36 --- /dev/null +++ b/devtools/client/framework/test/allocations/head.js @@ -0,0 +1,250 @@ +/* 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"; + +// Load the tracker very first in order to ensure tracking all objects created by DevTools. +// This is especially important for allocation sites. We need to catch the global the +// earliest possible in order to ensure that all allocation objects come with a stack. +// +// If we want to track DevTools module loader we should ensure loading Loader.sys.mjs within +// the `testScript` Function. i.e. after having calling startRecordingAllocations. +let tracker, releaseTrackerLoader; +{ + const { + useDistinctSystemPrincipalLoader, + releaseDistinctSystemPrincipalLoader, + } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs" + ); + + const requester = {}; + const loader = useDistinctSystemPrincipalLoader(requester); + releaseTrackerLoader = () => releaseDistinctSystemPrincipalLoader(requester); + const { allocationTracker } = loader.require( + "chrome://mochitests/content/browser/devtools/shared/test-helpers/allocation-tracker.js" + ); + tracker = allocationTracker({ watchDevToolsGlobals: true }); +} + +// /!\ Be careful about imports/require +// +// Some tests may record the very first time we load a module. +// If we start loading them from here, we might only retrieve the already loaded +// module from the loader's cache. This would no longer highlight the cost +// of loading a new module from scratch. +// +// => Avoid loading devtools module as much as possible +// => If you really have to, lazy load them + +ChromeUtils.defineLazyGetter(this, "TrackedObjects", () => { + return ChromeUtils.importESModule( + "resource://devtools/shared/test-helpers/tracked-objects.sys.mjs" + ); +}); + +// So that PERFHERDER data can be extracted from the logs. +SimpleTest.requestCompleteLog(); + +// We have to disable testing mode, or various debug instructions are enabled. +// We especially want to disable redux store history, which would leak all the actions! +SpecialPowers.pushPrefEnv({ + set: [["devtools.testing", false]], +}); + +// Set DEBUG_DEVTOOLS_ALLOCATIONS=allocations|leaks in order print debug informations. +const DEBUG_ALLOCATIONS = Services.env.get("DEBUG_DEVTOOLS_ALLOCATIONS"); + +async function addTab(url) { + const tab = BrowserTestUtils.addTab(gBrowser, url); + gBrowser.selectedTab = tab; + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + return tab; +} + +/** + * This function will force some garbage collection before recording + * data about allocated objects. + * + * This accept an optional boolean to also record the content process objects + * of the current tab. That, in addition of objects from the parent process, + * which are always recorded. + * + * This return same data object which is meant to be passed to `stopRecordingAllocations` as-is. + * + * See README.md file in this folder. + */ +async function startRecordingAllocations({ + alsoRecordContentProcess = false, +} = {}) { + // Also start recording allocations in the content process, if requested + if (alsoRecordContentProcess) { + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [DEBUG_ALLOCATIONS], + async debug_allocations => { + const { DevToolsLoader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + + const { + useDistinctSystemPrincipalLoader, + releaseDistinctSystemPrincipalLoader, + } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs" + ); + + const requester = {}; + const loader = useDistinctSystemPrincipalLoader(requester); + const { allocationTracker } = loader.require( + "chrome://mochitests/content/browser/devtools/shared/test-helpers/allocation-tracker.js" + ); + // We watch all globals in the content process, (instead of only DevTools global in parent process) + // because we may easily leak web page objects, which aren't in DevTools global. + const tracker = allocationTracker({ watchAllGlobals: true }); + + // /!\ HACK: store tracker and releaseTrackerLoader on DevToolsLoader in order + // to be able to reuse them in a following call to SpecialPowers.spawn + DevToolsLoader.tracker = tracker; + DevToolsLoader.releaseTrackerLoader = () => + releaseDistinctSystemPrincipalLoader(requester); + + await tracker.startRecordingAllocations(debug_allocations); + } + ); + // Trigger a GC in the parent process as this additional ContentTask + // seems to make harder to release objects created before we start recording. + await tracker.doGC(); + } + + await tracker.startRecordingAllocations(DEBUG_ALLOCATIONS); +} + +/** + * See doc of startRecordingAllocations + */ +async function stopRecordingAllocations( + recordName, + { alsoRecordContentProcess = false } = {} +) { + // Ensure that Memory API didn't ran out of buffers + ok(!tracker.overflowed, "Allocation were all recorded in the parent process"); + + // And finally, retrieve the record *after* having ran the test + const parentProcessData = await tracker.stopRecordingAllocations( + DEBUG_ALLOCATIONS + ); + + const objectNodeIds = TrackedObjects.getAllNodeIds(); + if (objectNodeIds.length) { + tracker.traceObjects(objectNodeIds); + } + + let contentProcessData = null; + if (alsoRecordContentProcess) { + contentProcessData = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [DEBUG_ALLOCATIONS], + debug_allocations => { + const { DevToolsLoader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { tracker } = DevToolsLoader; + ok( + !tracker.overflowed, + "Allocation were all recorded in the content process" + ); + return tracker.stopRecordingAllocations(debug_allocations); + } + ); + } + + const trackedObjectsInContent = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + const TrackedObjects = ChromeUtils.importESModule( + "resource://devtools/shared/test-helpers/tracked-objects.sys.mjs" + ); + const objectNodeIds = TrackedObjects.getAllNodeIds(); + if (objectNodeIds.length) { + const { DevToolsLoader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { tracker } = DevToolsLoader; + // Record the heap snapshot from the content process, + // and pass the record's filepath to the parent process + // As only the parent process can read the file because + // of sandbox restrictions made to content processes regarding file I/O. + const snapshotFile = tracker.getSnapshotFile(); + return { snapshotFile, objectNodeIds }; + } + return null; + } + ); + if (trackedObjectsInContent) { + tracker.traceObjects( + trackedObjectsInContent.objectNodeIds, + trackedObjectsInContent.snapshotFile + ); + } + + // Craft the JSON object required to save data in talos database + info( + `The ${recordName} test leaked ${parentProcessData.objectsWithStack} objects (${parentProcessData.objectsWithoutStack} with missing allocation site) in the parent process` + ); + const PERFHERDER_DATA = { + framework: { + name: "devtools", + }, + suites: [ + { + name: recordName + ":parent-process", + subtests: [ + { + name: "objects-with-stacks", + value: parentProcessData.objectsWithStack, + }, + { + name: "memory", + value: parentProcessData.memory, + }, + ], + }, + ], + }; + if (alsoRecordContentProcess) { + info( + `The ${recordName} test leaked ${contentProcessData.objectsWithStack} objects (${contentProcessData.objectsWithoutStack} with missing allocation site) in the content process` + ); + PERFHERDER_DATA.suites.push({ + name: recordName + ":content-process", + subtests: [ + { + name: "objects-with-stacks", + value: contentProcessData.objectsWithStack, + }, + { + name: "memory", + value: contentProcessData.memory, + }, + ], + }); + + // Finally release the tracker loader in content process. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const { DevToolsLoader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + DevToolsLoader.releaseTrackerLoader(); + }); + } + + // And release the tracker loader in the parent process + releaseTrackerLoader(); + + // Log it to stdout so that perfherder can collect this data. + // This only works if we called `SimpleTest.requestCompleteLog()`! + info("PERFHERDER_DATA: " + JSON.stringify(PERFHERDER_DATA)); +} diff --git a/devtools/client/framework/test/allocations/moz.build b/devtools/client/framework/test/allocations/moz.build new file mode 100644 index 0000000000..5488c8e2fc --- /dev/null +++ b/devtools/client/framework/test/allocations/moz.build @@ -0,0 +1,16 @@ +# -*- 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 += [ + "browser_allocations_browser_console.toml", + "browser_allocations_reload_debugger.toml", + "browser_allocations_reload_inspector.toml", + "browser_allocations_reload_netmonitor.toml", + "browser_allocations_reload_no_devtools.toml", + "browser_allocations_reload_webconsole.toml", + "browser_allocations_target.toml", + "browser_allocations_toolbox.toml", +] diff --git a/devtools/client/framework/test/allocations/reload-test.js b/devtools/client/framework/test/allocations/reload-test.js new file mode 100644 index 0000000000..3d09d5ef43 --- /dev/null +++ b/devtools/client/framework/test/allocations/reload-test.js @@ -0,0 +1,84 @@ +/* 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"; + +/* import-globals-from head.js */ +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +/** + * Generate a test Task to record allocation when reloading a test page + * while having one particular DevTools panel opened + * + * @param String recordName + * Name of the test recorded into PerfHerder/Talos database + * @param String toolId + * ID of the panel to open + */ +function createPanelReloadTest(recordName, toolId) { + return async function panelReloadTest() { + const TEST_URL = + "http://example.com/browser/devtools/client/framework/test/allocations/reloaded-page.html"; + + async function testScript(toolbox) { + const onTargetSwitched = + toolbox.commands.targetCommand.once("switched-target"); + const onReloaded = toolbox.getCurrentPanel().once("reloaded"); + + gBrowser.reloadTab(gBrowser.selectedTab); + + if ( + toolbox.commands.targetCommand.targetFront.targetForm + .followWindowGlobalLifeCycle + ) { + info("Wait for target switched"); + await onTargetSwitched; + } + + info("Wait for panel reload"); + await onReloaded; + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + const tab = await addTab(TEST_URL); + + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + gDevTools, + } = require("resource://devtools/client/framework/devtools.js"); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId, + }); + + // Run the test scenario first before recording in order to load all the + // modules. Otherwise they get reported as "still allocated" objects, + // whereas we do expect them to be kept in memory as they are loaded via + // the main DevTools loader, which keeps the module loaded until the + // shutdown of Firefox + await testScript(toolbox); + // Running it a second time is helpful for the debugger which allocates different objects + // on the second run... which would be taken as leak otherwise. + await testScript(toolbox); + + await startRecordingAllocations({ + alsoRecordContentProcess: true, + }); + + // Now, run the test script. This time, we record this run. + for (let i = 0; i < 10; i++) { + await testScript(toolbox); + } + + await stopRecordingAllocations(recordName, { + alsoRecordContentProcess: true, + }); + + await toolbox.destroy(); + gBrowser.removeTab(tab); + }; +} diff --git a/devtools/client/framework/test/allocations/reloaded-page.html b/devtools/client/framework/test/allocations/reloaded-page.html new file mode 100644 index 0000000000..4f14c8a0c3 --- /dev/null +++ b/devtools/client/framework/test/allocations/reloaded-page.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> + <head> + <title>Reloaded page</title> + <meta charset="UTF-8"> + </head> + <body> + The reloaded page + <img src="reloaded.png" /> + </body> +</html> diff --git a/devtools/client/framework/test/allocations/reloaded.png b/devtools/client/framework/test/allocations/reloaded.png Binary files differnew file mode 100644 index 0000000000..769c636340 --- /dev/null +++ b/devtools/client/framework/test/allocations/reloaded.png diff --git a/devtools/client/framework/test/browser-telemetry-startup.toml b/devtools/client/framework/test/browser-telemetry-startup.toml new file mode 100644 index 0000000000..12b04456e7 --- /dev/null +++ b/devtools/client/framework/test/browser-telemetry-startup.toml @@ -0,0 +1,14 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "head.js", + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", +] + +["browser_toolbox_telemetry_open_event.js"] +# This test suite is dedicated to run a test for the telemetry event logged when +# opening the toolbox for the first time. This test has to be the first test +# running for a given instance of Firefox. A dedicated manifest will ensure a +# new browser instance is created just for this test. diff --git a/devtools/client/framework/test/browser.toml b/devtools/client/framework/test/browser.toml new file mode 100644 index 0000000000..e7d979ebb3 --- /dev/null +++ b/devtools/client/framework/test/browser.toml @@ -0,0 +1,315 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "reload/*", + "browser_toolbox_options_disable_js.html", + "browser_toolbox_options_disable_js_iframe.html", + "browser_toolbox_options_disable_cache.sjs", + "browser_toolbox_options_disable_cache.css.sjs", + "browser_toolbox_window_title_changes_page.html", + "browser_toolbox_window_title_frame_select_page.html", + "code_bundle_late_script.js", + "code_bundle_late_script.js.map", + "code_binary_search.coffee", + "code_binary_search.js", + "code_binary_search.map", + "code_binary_search_absolute.js", + "code_binary_search_absolute.map", + "code_bundle_cross_domain.js", + "code_bundle_cross_domain.js.map", + "code_bundle_no_race.js", + "code_bundle_no_race.js.map", + "code_cross_domain.js", + "code_inline_bundle.js", + "code_inline_original.js", + "code_math.js", + "code_no_race.js", + "doc_backward_forward_navigation.html", + "doc_cached-resource.html", + "doc_cached-resource_iframe.html", + "doc_empty-tab-01.html", + "doc_lazy_tool.html", + "doc_textbox_tool.html", + "head.js", + "helper_disable_cache.js", + "doc_theme.css", + "doc_viewsource.html", + "browser_toolbox_options_enable_serviceworkers_testing.html", + "serviceworker.js", + "sjs_cache_controle_header.sjs", + "test_chrome_page.html", + "!/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/webconsole/test/browser/shared-head.js", +] +prefs = ["security.allow_unsafe_parent_loads=true"] # This is far from ideal. Bug 1565279 covers removing this pref flip. + +["../../../../browser/base/content/test/static/browser_all_files_referenced.js"] +# We want this test to run for mochitest-dt as well, so we include it here +skip-if = [ + "debug", # no point in running on both opt and debug, and will likely intermittently timeout on debug, Bug 1598726 + "asan", + "ccov", +] + +["../../../../browser/base/content/test/static/browser_parsable_css.js"] +# We want this test to run for mochitest-dt as well, so we include it here +skip-if = [ + "debug", # no point in running on both opt and debug, and will likely intermittently timeout on debug + "asan", +] + +["browser_about-devtools-toolbox_load.js"] + +["browser_about-devtools-toolbox_reload.js"] + +["browser_commands_from_url.js"] + +["browser_devtools_api_destroy.js"] + +["browser_dynamic_tool_enabling.js"] + +["browser_front_parentFront.js"] + +["browser_ignore_toolbox_network_requests.js"] + +["browser_keybindings_01.js"] + +["browser_keybindings_02.js"] + +["browser_keybindings_03.js"] + +["browser_menu_api.js"] + +["browser_new_activation_workflow.js"] + +["browser_source_map-01.js"] + +["browser_source_map-absolute.js"] + +["browser_source_map-cross-domain.js"] + +["browser_source_map-init.js"] + +["browser_source_map-inline.js"] + +["browser_source_map-late-script.js"] + +["browser_source_map-no-race.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_source_map-pub-sub.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_source_map-reload.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_tab_commands_factory.js"] + +["browser_tab_descriptor_fission.js"] + +["browser_target_cached-front.js"] + +["browser_target_cached-resource.js"] + +["browser_target_get-front.js"] + +["browser_target_listeners.js"] + +["browser_target_loading.js"] + +["browser_target_parents.js"] +skip-if = ["tsan"] # bug 1807041 + +["browser_target_remote.js"] + +["browser_target_server_compartment.js"] + +["browser_target_support.js"] + +["browser_toolbox_backward_forward_navigation.js"] +skip-if = [ + "os == 'linux' && bits == 64", # Bug 1770314 + "os == 'mac'", # Bug 1770314 +] + +["browser_toolbox_browsertoolbox_host.js"] + +["browser_toolbox_contentpage_contextmenu.js"] + +["browser_toolbox_disable_f12.js"] + +["browser_toolbox_dynamic_registration.js"] + +["browser_toolbox_error_count.js"] + +["browser_toolbox_error_count_reset_on_navigation.js"] + +["browser_toolbox_fission_navigation.js"] +skip-if = [ + "os == 'linux'", # Bug 1742672 +] + +["browser_toolbox_frames_list.js"] + +["browser_toolbox_getpanelwhenready.js"] + +["browser_toolbox_highlight.js"] + +["browser_toolbox_hosts.js"] + +["browser_toolbox_hosts_size.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_toolbox_hosts_telemetry.js"] + +["browser_toolbox_keyboard_navigation.js"] + +["browser_toolbox_keyboard_navigation_notification_box.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_toolbox_meatball.js"] + +["browser_toolbox_options.js"] + +["browser_toolbox_options_disable_buttons.js"] +skip-if = ["a11y_checks"] # Bug 1849028 and 1849179 for causing crashes + +["browser_toolbox_options_disable_cache-01.js"] + +["browser_toolbox_options_disable_cache-02.js"] + +["browser_toolbox_options_disable_cache-03.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_toolbox_options_disable_js.js"] + +["browser_toolbox_options_enable_serviceworkers_testing.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_toolbox_options_frames_button.js"] + +["browser_toolbox_options_multiple_tabs.js"] + +["browser_toolbox_options_panel_toggle.js"] + +["browser_toolbox_popups_debugging.js"] + +["browser_toolbox_races.js"] + +["browser_toolbox_raise.js"] +disabled = "Bug 962258" + +["browser_toolbox_ready.js"] + +["browser_toolbox_remoteness_change.js"] + +["browser_toolbox_screenshot_tool.js"] +skip-if = ["a11y_checks"] # Bugs 1858041 and 1849028 for causing intermittent crashes + +["browser_toolbox_select_event.js"] + +["browser_toolbox_selected_tool_unavailable.js"] + +["browser_toolbox_selectionchanged_event.js"] + +["browser_toolbox_show_toolbox_tool_ready.js"] + +["browser_toolbox_split_console.js"] + +["browser_toolbox_tabsswitch_shortcuts.js"] + +["browser_toolbox_telemetry_activate_splitconsole.js"] + +["browser_toolbox_telemetry_close.js"] + +["browser_toolbox_telemetry_enter.js"] + +["browser_toolbox_telemetry_exit.js"] + +["browser_toolbox_textbox_context_menu.js"] + +["browser_toolbox_theme.js"] + +["browser_toolbox_theme_registration.js"] + +["browser_toolbox_toggle.js"] + +["browser_toolbox_tool_ready.js"] + +["browser_toolbox_tool_remote_reopen.js"] + +["browser_toolbox_toolbar_minimum_width.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled +skip-if = ["os == 'win' && !debug"] # Bug 1709840 + +["browser_toolbox_toolbar_overflow.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_toolbox_toolbar_overflow_button_visibility.js"] + +["browser_toolbox_toolbar_reorder_by_dnd.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_toolbox_toolbar_reorder_by_width.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_toolbox_toolbar_reorder_with_extension.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_toolbox_toolbar_reorder_with_hidden_extension.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_toolbox_tools_per_toolbox_registration.js"] + +["browser_toolbox_view_source_01.js"] + +["browser_toolbox_view_source_02.js"] + +["browser_toolbox_view_source_03.js"] + +["browser_toolbox_view_source_style_editor_fallback.js"] + +["browser_toolbox_watchedByDevTools.js"] + +["browser_toolbox_window_reload_target.js"] + +["browser_toolbox_window_reload_target_force.js"] + +["browser_toolbox_window_shortcuts.js"] + +["browser_toolbox_window_title_changes.js"] + +["browser_toolbox_window_title_frame_select.js"] + +["browser_toolbox_zoom.js"] +skip-if = ["os == 'win' && !debug"] # bug 1683265 + +["browser_toolbox_zoom_popup.js"] + +["browser_webextension_descriptor.js"] + +["browser_webextension_dropdown.js"] +skip-if = ["os == 'linux' && !debug"] # Bug 1714106 +# We want these tests to run for mochitest-dt as well, so we include them here: diff --git a/devtools/client/framework/test/browser_about-devtools-toolbox_load.js b/devtools/client/framework/test/browser_about-devtools-toolbox_load.js new file mode 100644 index 0000000000..abcb59a5d6 --- /dev/null +++ b/devtools/client/framework/test/browser_about-devtools-toolbox_load.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that about:devtools-toolbox shows error an page when opened with invalid + * paramters + */ +add_task(async function () { + // test that error is shown when missing `type` param + let { document, tab } = await openAboutToolbox({ invalid: "invalid" }); + await assertErrorIsShown(document); + await removeTab(tab); + // test that error is shown if `id` is not provided + ({ document, tab } = await openAboutToolbox({ type: "tab" })); + await assertErrorIsShown(document); + await removeTab(tab); + // test that error is shown if `remoteId` refers to an unexisting target + ({ document, tab } = await openAboutToolbox({ + type: "tab", + remoteId: "13371337", + })); + await assertErrorIsShown(document); + await removeTab(tab); + + async function assertErrorIsShown(doc) { + await waitUntil(() => doc.querySelector(".qa-error-page")); + ok(doc.querySelector(".qa-error-page"), "Error page is rendered"); + } +}); diff --git a/devtools/client/framework/test/browser_about-devtools-toolbox_reload.js b/devtools/client/framework/test/browser_about-devtools-toolbox_reload.js new file mode 100644 index 0000000000..f350816b24 --- /dev/null +++ b/devtools/client/framework/test/browser_about-devtools-toolbox_reload.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that about:devtools-toolbox is reloaded correctly when reusing the same debugger + * client instance. + */ +add_task(async function () { + const devToolsClient = await createLocalClient(); + + info( + "Preload a local DevToolsClient as this-firefox in the remoteClientManager" + ); + const { + remoteClientManager, + } = require("resource://devtools/client/shared/remote-debugging/remote-client-manager.js"); + remoteClientManager.setClient( + "this-firefox", + "this-firefox", + devToolsClient, + {} + ); + registerCleanupFunction(() => { + remoteClientManager.removeAllClients(); + }); + + info("Create a dummy target tab"); + const targetTab = await addTab("data:text/html,somehtml"); + + let onToolboxReady = gDevTools.once("toolbox-ready"); + const { tab } = await openAboutToolbox({ + id: targetTab.linkedBrowser.browserId, + remoteId: "this-firefox-this-firefox", + type: "tab", + }); + await onToolboxReady; + + info("Reload about:devtools-toolbox page"); + onToolboxReady = gDevTools.once("toolbox-ready"); + tab.linkedBrowser.reload(); + await onToolboxReady; + + info("Check if about:devtools-toolbox was reloaded correctly"); + const refreshedDoc = tab.linkedBrowser.contentDocument; + ok( + refreshedDoc.querySelector(".debug-target-info"), + "about:devtools-toolbox header is correctly displayed" + ); + + const onToolboxDestroy = gDevTools.once("toolbox-destroyed"); + await removeTab(tab); + await onToolboxDestroy; + await devToolsClient.close(); + await removeTab(targetTab); +}); + +async function createLocalClient() { + const { + DevToolsClient, + } = require("resource://devtools/client/devtools-client.js"); + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + DevToolsServer.allowChromeProcess = true; + + const devToolsClient = new DevToolsClient(DevToolsServer.connectPipe()); + await devToolsClient.connect(); + return devToolsClient; +} diff --git a/devtools/client/framework/test/browser_commands_from_url.js b/devtools/client/framework/test/browser_commands_from_url.js new file mode 100644 index 0000000000..6d1412005c --- /dev/null +++ b/devtools/client/framework/test/browser_commands_from_url.js @@ -0,0 +1,161 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URI = + "data:text/html;charset=utf-8," + "<p>browser_target-from-url.js</p>"; + +const { DevToolsLoader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const { + commandsFromURL, +} = require("resource://devtools/client/framework/commands-from-url.js"); + +Services.prefs.setBoolPref("devtools.debugger.remote-enabled", true); +Services.prefs.setBoolPref("devtools.debugger.prompt-connection", false); + +SimpleTest.registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.debugger.remote-enabled"); + Services.prefs.clearUserPref("devtools.debugger.prompt-connection"); +}); + +function assertTarget(target, url) { + is(target.url, url); + is(target.isBrowsingContext, true); +} + +add_task(async function () { + const tab = await addTab(TEST_URI); + const browser = tab.linkedBrowser; + let commands, target; + + info("Test invalid type"); + try { + await commandsFromURL(new URL("https://foo?type=x")); + ok(false, "Shouldn't pass"); + } catch (e) { + is(e.message, "commandsFromURL, unsupported type 'x' parameter"); + } + + info("Test tab"); + commands = await commandsFromURL( + new URL("https://foo?type=tab&id=" + browser.browserId) + ); + // Descriptor's getTarget will only work if the TargetCommand watches for the first top target + await commands.targetCommand.startListening(); + + // For now, we can't spawn a commands flagged as 'local tab' via URL query params + // The only way to has isLocalTab is to create the toolbox via showToolboxForTab + // and spawn the command via CommandsFactory.forTab. + is( + commands.descriptorFront.isLocalTab, + false, + "Even if we refer to a local tab, isLocalTab is false (for now)" + ); + + target = await commands.descriptorFront.getTarget(); + + assertTarget(target, TEST_URI); + await commands.destroy(); + + info("Test invalid tab id"); + try { + await commandsFromURL(new URL("https://foo?type=tab&id=10000")); + ok(false, "Shouldn't pass"); + } catch (e) { + is(e.message, "commandsFromURL, tab with browserId '10000' doesn't exist"); + } + + info("Test parent process"); + commands = await commandsFromURL(new URL("https://foo?type=process")); + target = await commands.descriptorFront.getTarget(); + const topWindow = Services.wm.getMostRecentWindow("navigator:browser"); + assertTarget(target, topWindow.location.href); + await commands.destroy(); + + await testRemoteTCP(); + await testRemoteWebSocket(); + + gBrowser.removeCurrentTab(); +}); + +async function setupDevToolsServer(webSocket) { + info("Create a separate loader instance for the DevToolsServer."); + const loader = new DevToolsLoader(); + const { DevToolsServer } = loader.require( + "resource://devtools/server/devtools-server.js" + ); + const { SocketListener } = loader.require( + "resource://devtools/shared/security/socket.js" + ); + + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + DevToolsServer.allowChromeProcess = true; + const socketOptions = { + // Pass -1 to automatically choose an available port + portOrPath: -1, + webSocket, + }; + + const listener = new SocketListener(DevToolsServer, socketOptions); + ok(listener, "Socket listener created"); + await listener.open(); + is(DevToolsServer.listeningSockets, 1, "1 listening socket"); + + return { DevToolsServer, listener }; +} + +function teardownDevToolsServer({ DevToolsServer, listener }) { + info("Close the listener socket"); + listener.close(); + is(DevToolsServer.listeningSockets, 0, "0 listening sockets"); + + info("Destroy the temporary devtools server"); + DevToolsServer.destroy(); +} + +async function testRemoteTCP() { + info("Test remote process via TCP Connection"); + + const server = await setupDevToolsServer(false); + + const { port } = server.listener; + const commands = await commandsFromURL( + new URL("https://foo?type=process&host=127.0.0.1&port=" + port) + ); + const target = await commands.descriptorFront.getTarget(); + const topWindow = Services.wm.getMostRecentWindow("navigator:browser"); + assertTarget(target, topWindow.location.href); + + const settings = commands.client._transport.connectionSettings; + is(settings.host, "127.0.0.1"); + is(parseInt(settings.port, 10), port); + is(settings.webSocket, false); + + await commands.destroy(); + + teardownDevToolsServer(server); +} + +async function testRemoteWebSocket() { + info("Test remote process via WebSocket Connection"); + + const server = await setupDevToolsServer(true); + + const { port } = server.listener; + const commands = await commandsFromURL( + new URL("https://foo?type=process&host=127.0.0.1&port=" + port + "&ws=true") + ); + const target = await commands.descriptorFront.getTarget(); + const topWindow = Services.wm.getMostRecentWindow("navigator:browser"); + assertTarget(target, topWindow.location.href); + + const settings = commands.client._transport.connectionSettings; + is(settings.host, "127.0.0.1"); + is(parseInt(settings.port, 10), port); + is(settings.webSocket, true); + await commands.destroy(); + + teardownDevToolsServer(server); +} diff --git a/devtools/client/framework/test/browser_devtools_api_destroy.js b/devtools/client/framework/test/browser_devtools_api_destroy.js new file mode 100644 index 0000000000..736455df65 --- /dev/null +++ b/devtools/client/framework/test/browser_devtools_api_destroy.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests devtools API + +function test() { + addTab("about:blank").then(runTests); +} + +async function runTests(aTab) { + const toolDefinition = { + id: "testTool", + visibilityswitch: "devtools.testTool.enabled", + isToolSupported: () => true, + url: "about:blank", + label: "someLabel", + build(iframeWindow, toolbox) { + return new Promise(resolve => { + executeSoon(() => { + resolve({ + target: toolbox.target, + toolbox, + isReady: true, + destroy() {}, + }); + }); + }); + }, + }; + + gDevTools.registerTool(toolDefinition); + + const collectedEvents = []; + + gDevTools + .showToolboxForTab(aTab, { toolId: toolDefinition.id }) + .then(function (toolbox) { + const panel = toolbox.getPanel(toolDefinition.id); + ok(panel, "Tool open"); + + gDevTools.once("toolbox-destroy", (toolbox, iframe) => { + collectedEvents.push("toolbox-destroy"); + }); + + gDevTools.once(toolDefinition.id + "-destroy", (toolbox, iframe) => { + collectedEvents.push("gDevTools-" + toolDefinition.id + "-destroy"); + }); + + toolbox.once("destroy", () => { + collectedEvents.push("destroy"); + }); + + toolbox.once(toolDefinition.id + "-destroy", () => { + collectedEvents.push("toolbox-" + toolDefinition.id + "-destroy"); + }); + + toolbox.destroy().then(function () { + is( + collectedEvents.join(":"), + "toolbox-destroy:destroy:gDevTools-testTool-destroy:toolbox-testTool-destroy", + "Found the right amount of collected events." + ); + + gDevTools.unregisterTool(toolDefinition.id); + gBrowser.removeCurrentTab(); + + executeSoon(function () { + finish(); + }); + }); + }); +} diff --git a/devtools/client/framework/test/browser_dynamic_tool_enabling.js b/devtools/client/framework/test/browser_dynamic_tool_enabling.js new file mode 100644 index 0000000000..56313607cf --- /dev/null +++ b/devtools/client/framework/test/browser_dynamic_tool_enabling.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that toggling prefs immediately (de)activates the relevant menuitem + +var gItemsToTest = { + menu_browserToolbox: [ + "devtools.chrome.enabled", + "devtools.debugger.remote-enabled", + ], +}; + +function expectedAttributeValueFromPrefs(prefs) { + return prefs.every(pref => Services.prefs.getBoolPref(pref)) ? "" : "true"; +} + +function checkItem(el, prefs) { + const expectedValue = expectedAttributeValueFromPrefs(prefs); + is( + el.getAttribute("disabled"), + expectedValue, + "disabled attribute should match current pref state" + ); + is( + el.getAttribute("hidden"), + expectedValue, + "hidden attribute should match current pref state" + ); +} + +function test() { + for (const k in gItemsToTest) { + const el = document.getElementById(k); + const prefs = gItemsToTest[k]; + checkItem(el, prefs); + for (const pref of prefs) { + Services.prefs.setBoolPref(pref, !Services.prefs.getBoolPref(pref)); + checkItem(el, prefs); + Services.prefs.setBoolPref(pref, !Services.prefs.getBoolPref(pref)); + checkItem(el, prefs); + } + } + finish(); +} diff --git a/devtools/client/framework/test/browser_front_parentFront.js b/devtools/client/framework/test/browser_front_parentFront.js new file mode 100644 index 0000000000..106632dd45 --- /dev/null +++ b/devtools/client/framework/test/browser_front_parentFront.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the Front's parentFront attribute returns the correct parent front. + +const TEST_URL = `data:text/html;charset=utf-8,<div id="test"></div>`; + +add_task(async function () { + const tab = await addTab(TEST_URL); + const target = await createAndAttachTargetForTab(tab); + + const inspectorFront = await target.getFront("inspector"); + const walker = inspectorFront.walker; + const pageStyleFront = await inspectorFront.getPageStyle(); + const nodeFront = await walker.querySelector(walker.rootNode, "#test"); + + is( + inspectorFront.parentFront, + target, + "Got the correct parentFront from the InspectorFront." + ); + is( + walker.parentFront, + inspectorFront, + "Got the correct parentFront from the WalkerFront." + ); + is( + pageStyleFront.parentFront, + inspectorFront, + "Got the correct parentFront from the PageStyleFront." + ); + is( + nodeFront.parentFront, + walker, + "Got the correct parentFront from the NodeFront." + ); +}); diff --git a/devtools/client/framework/test/browser_ignore_toolbox_network_requests.js b/devtools/client/framework/test/browser_ignore_toolbox_network_requests.js new file mode 100644 index 0000000000..65daa4d78d --- /dev/null +++ b/devtools/client/framework/test/browser_ignore_toolbox_network_requests.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that network requests originating from the toolbox don't get recorded in +// the network panel. + +add_task(async function () { + let tab = await addTab(URL_ROOT + "doc_viewsource.html"); + let toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "styleeditor", + }); + let panel = toolbox.getPanel("styleeditor"); + + is(panel.UI.editors.length, 1, "correct number of editors opened"); + + const monitor = await toolbox.selectTool("netmonitor"); + const { store } = monitor.panelWin; + + is( + store.getState().requests.requests.length, + 0, + "No network requests appear in the network panel" + ); + + await toolbox.destroy(); + tab = toolbox = panel = null; + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_keybindings_01.js b/devtools/client/framework/test/browser_keybindings_01.js new file mode 100644 index 0000000000..968c3a3d3d --- /dev/null +++ b/devtools/client/framework/test/browser_keybindings_01.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(3); + +// Tests that the keybindings for opening and closing the inspector work as expected +// Can probably make this a shared test that tests all of the tools global keybindings +const TEST_URL = + "data:text/html,<html><head><title>Test for the " + + "highlighter keybindings</title></head><body>" + + "<h1>Keybindings!</h1></body></html>"; + +const { + gDevToolsBrowser, +} = require("resource://devtools/client/framework/devtools-browser.js"); + +const isMac = AppConstants.platform == "macosx"; + +const allKeys = []; +function buildDevtoolsKeysetMap(keyset) { + // Fetches all the keyboard shortcuts which were defined by lazyGetter 'KeyShortcuts' in + // devtools-startup.js and added to the DOM by 'hookKeyShortcuts' + [...keyset.querySelectorAll("key")].forEach(key => { + if (!key.getAttribute("key")) { + return; + } + + const modifiers = key.getAttribute("modifiers"); + allKeys.push({ + toolId: key.id.split("_")[1], + key: key.getAttribute("key"), + modifiers, + modifierOpt: { + shiftKey: modifiers.match("shift"), + ctrlKey: modifiers.match("ctrl"), + altKey: modifiers.match("alt"), + metaKey: modifiers.match("meta"), + accelKey: modifiers.match("accel"), + }, + synthesizeKey() { + EventUtils.synthesizeKey(this.key, this.modifierOpt); + }, + }); + }); +} + +function setupKeyBindingsTest() { + for (const win of gDevToolsBrowser._trackedBrowserWindows) { + buildDevtoolsKeysetMap(win.document.getElementById("devtoolsKeyset")); + } +} + +add_task(async function () { + await addTab(TEST_URL); + await new Promise(done => waitForFocus(done)); + + setupKeyBindingsTest(); + + const tests = [ + { id: "inspector", toolId: "inspector" }, + { id: "webconsole", toolId: "webconsole" }, + { id: "netmonitor", toolId: "netmonitor" }, + { id: "jsdebugger", toolId: "jsdebugger" }, + ]; + + // There are two possible keyboard shortcuts to open the inspector on macOS + if (isMac) { + tests.push({ id: "inspectorMac", toolId: "inspector" }); + } + + // Toolbox reference will be set by first tool to open. + let toolbox; + + for (const test of tests) { + const onToolboxReady = gDevTools.once("toolbox-ready"); + const onSelectTool = gDevTools.once("select-tool-command"); + + info(`Run the keyboard shortcut for ${test.id}`); + const key = allKeys.filter(({ toolId }) => toolId === test.id)[0]; + key.synthesizeKey(); + + if (!toolbox) { + toolbox = await onToolboxReady; + } + + if (test.toolId === "inspector") { + const onPickerStart = toolbox.nodePicker.once("picker-started"); + await onPickerStart; + ok(true, "picker-started event received, highlighter started"); + + info( + `Run the keyboard shortcut for ${test.id} again to stop the node picker` + ); + const onPickerStop = toolbox.nodePicker.once("picker-stopped"); + key.synthesizeKey(); + await onPickerStop; + ok(true, "picker-stopped event received, highlighter stopped"); + } + + await onSelectTool; + is(toolbox.currentToolId, test.toolId, `${test.toolId} should be selected`); + } + + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_keybindings_02.js b/devtools/client/framework/test/browser_keybindings_02.js new file mode 100644 index 0000000000..3be8309edf --- /dev/null +++ b/devtools/client/framework/test/browser_keybindings_02.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the toolbox keybindings still work after the host is changed. + +const URL = "data:text/html;charset=utf8,test page"; + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +function getZoomValue() { + return parseFloat(Services.prefs.getCharPref("devtools.toolbox.zoomValue")); +} + +add_task(async function () { + info("Create a test tab and open the toolbox"); + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab, "webconsole"); + + const { RIGHT, BOTTOM } = Toolbox.HostType; + for (const type of [RIGHT, BOTTOM, RIGHT]) { + info("Switch to host type " + type); + await toolbox.switchHost(type); + + info("Try to use the toolbox shortcuts"); + await checkKeyBindings(toolbox); + } + + Services.prefs.clearUserPref("devtools.toolbox.zoomValue"); + Services.prefs.setCharPref("devtools.toolbox.host", BOTTOM); + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +function zoomWithKey(toolbox, key) { + const shortcut = L10N.getStr(key); + if (!shortcut) { + info("Key was empty, skipping zoomWithKey"); + return; + } + info("Zooming with key: " + key); + const currentZoom = getZoomValue(); + synthesizeKeyShortcut(shortcut, toolbox.win); + isnot( + getZoomValue(), + currentZoom, + "The zoom level was changed in the toolbox" + ); +} + +function checkKeyBindings(toolbox) { + zoomWithKey(toolbox, "toolbox.zoomIn.key"); + zoomWithKey(toolbox, "toolbox.zoomIn2.key"); + + zoomWithKey(toolbox, "toolbox.zoomReset.key"); + + zoomWithKey(toolbox, "toolbox.zoomOut.key"); + zoomWithKey(toolbox, "toolbox.zoomOut2.key"); + + zoomWithKey(toolbox, "toolbox.zoomReset2.key"); +} diff --git a/devtools/client/framework/test/browser_keybindings_03.js b/devtools/client/framework/test/browser_keybindings_03.js new file mode 100644 index 0000000000..71ce51c1e0 --- /dev/null +++ b/devtools/client/framework/test/browser_keybindings_03.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the toolbox 'switch to previous host' feature works. +// Pressing ctrl/cmd+shift+d should switch to the last used host. + +const URL = "data:text/html;charset=utf8,test page for toolbox switching"; + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +add_task(async function () { + info("Create a test tab and open the toolbox"); + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab, "webconsole"); + + const shortcut = L10N.getStr("toolbox.toggleHost.key"); + + const { RIGHT, BOTTOM, WINDOW } = Toolbox.HostType; + checkHostType(toolbox, BOTTOM, RIGHT); + + info("Switching from bottom to right"); + let onHostChanged = toolbox.once("host-changed"); + synthesizeKeyShortcut(shortcut, toolbox.win); + await onHostChanged; + checkHostType(toolbox, RIGHT, BOTTOM); + + info("Switching from right to bottom"); + onHostChanged = toolbox.once("host-changed"); + synthesizeKeyShortcut(shortcut, toolbox.win); + await onHostChanged; + checkHostType(toolbox, BOTTOM, RIGHT); + + info("Switching to window"); + await toolbox.switchHost(WINDOW); + checkHostType(toolbox, WINDOW, BOTTOM); + + info("Switching from window to bottom"); + onHostChanged = toolbox.once("host-changed"); + synthesizeKeyShortcut(shortcut, toolbox.win); + await onHostChanged; + checkHostType(toolbox, BOTTOM, WINDOW); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_menu_api.js b/devtools/client/framework/test/browser_menu_api.js new file mode 100644 index 0000000000..daa69cf8dd --- /dev/null +++ b/devtools/client/framework/test/browser_menu_api.js @@ -0,0 +1,239 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the Menu API works + +const URL = "data:text/html;charset=utf8,test page for menu api"; +const Menu = require("resource://devtools/client/framework/menu.js"); +const MenuItem = require("resource://devtools/client/framework/menu-item.js"); + +add_task(async function () { + info("Create a test tab and open the toolbox"); + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + }); + + // This test will involve localized strings, make sure the necessary FTL file is + // available in the toolbox top window. + toolbox.topWindow.MozXULElement.insertFTLIfNeeded( + "toolkit/global/textActions.ftl" + ); + + loadFTL(toolbox, "toolkit/global/textActions.ftl"); + + await testMenuItems(); + await testMenuPopup(toolbox); + await testSubmenu(toolbox); +}); + +function testMenuItems() { + const menu = new Menu(); + const menuItem1 = new MenuItem(); + const menuItem2 = new MenuItem(); + + menu.append(menuItem1); + menu.append(menuItem2); + + is(menu.items.length, 2, "Correct number of 'items'"); + is(menu.items[0], menuItem1, "Correct reference to MenuItem"); + is(menu.items[1], menuItem2, "Correct reference to MenuItem"); +} + +async function testMenuPopup(toolbox) { + let clickFired = false; + + const menu = new Menu({ + id: "menu-popup", + }); + menu.append(new MenuItem({ type: "separator" })); + + const MENU_ITEMS = [ + new MenuItem({ + id: "menu-item-1", + label: "Normal Item", + click: () => { + info("Click callback has fired for menu item"); + clickFired = true; + }, + }), + new MenuItem({ + label: "Checked Item", + type: "checkbox", + checked: true, + }), + new MenuItem({ + label: "Radio Item", + type: "radio", + }), + new MenuItem({ + label: "Disabled Item", + disabled: true, + }), + new MenuItem({ + l10nID: "text-action-undo", + }), + ]; + + for (const item of MENU_ITEMS) { + menu.append(item); + } + + // Append an invisible MenuItem, which shouldn't show up in the DOM + menu.append( + new MenuItem({ + label: "Invisible", + visible: false, + }) + ); + + menu.popup(0, 0, toolbox.doc); + const popup = toolbox.topDoc.querySelector("#menu-popup"); + ok(popup, "A popup is in the DOM"); + + const menuSeparators = toolbox.topDoc.querySelectorAll( + "#menu-popup > menuseparator" + ); + is(menuSeparators.length, 1, "A separator is in the menu"); + + const menuItems = toolbox.topDoc.querySelectorAll("#menu-popup > menuitem"); + is(menuItems.length, MENU_ITEMS.length, "Correct number of menuitems"); + + is(menuItems[0].id, MENU_ITEMS[0].id, "Correct id for menuitem"); + is(menuItems[0].getAttribute("label"), MENU_ITEMS[0].label, "Correct label"); + + is(menuItems[1].getAttribute("label"), MENU_ITEMS[1].label, "Correct label"); + is(menuItems[1].getAttribute("type"), "checkbox", "Correct type attr"); + is(menuItems[1].getAttribute("checked"), "true", "Has checked attr"); + + is(menuItems[2].getAttribute("label"), MENU_ITEMS[2].label, "Correct label"); + is(menuItems[2].getAttribute("type"), "radio", "Correct type attr"); + ok(!menuItems[2].hasAttribute("checked"), "Doesn't have checked attr"); + + is(menuItems[3].getAttribute("label"), MENU_ITEMS[3].label, "Correct label"); + is(menuItems[3].getAttribute("disabled"), "true", "disabled attr menuitem"); + + is( + menuItems[4].getAttribute("data-l10n-id"), + MENU_ITEMS[4].l10nID, + "Correct localization attribute" + ); + + await once(menu, "open"); + const closed = once(menu, "close"); + popup.activateItem(menuItems[0]); + await closed; + ok(clickFired, "Click has fired"); + + ok( + !toolbox.topDoc.querySelector("#menu-popup"), + "Popup removed from the DOM" + ); +} + +async function testSubmenu(toolbox) { + let clickFired = false; + const menu = new Menu({ + id: "menu-popup", + }); + const submenu = new Menu({ + id: "submenu-popup", + }); + submenu.append( + new MenuItem({ + label: "Submenu item", + click: () => { + info("Click callback has fired for submenu item"); + clickFired = true; + }, + }) + ); + menu.append( + new MenuItem({ + l10nID: "text-action-copy", + submenu, + }) + ); + menu.append( + new MenuItem({ + label: "Submenu parent with attributes", + id: "submenu-parent-with-attrs", + submenu, + accesskey: "A", + disabled: true, + }) + ); + + menu.popup(0, 0, toolbox.doc); + const popup = toolbox.topDoc.querySelector("#menu-popup"); + ok(popup, "A popup is in the DOM"); + is( + toolbox.topDoc.querySelectorAll("#menu-popup > menuitem").length, + 0, + "No menuitem children" + ); + + const menus = toolbox.topDoc.querySelectorAll("#menu-popup > menu"); + is(menus.length, 2, "Correct number of menus"); + ok( + !menus[0].hasAttribute("label"), + "No label: should be set by localization" + ); + ok(!menus[0].hasAttribute("disabled"), "Correct disabled state"); + is( + menus[0].getAttribute("data-l10n-id"), + "text-action-copy", + "Correct localization attribute" + ); + + is(menus[1].getAttribute("accesskey"), "A", "Correct accesskey"); + ok(menus[1].hasAttribute("disabled"), "Correct disabled state"); + is(menus[1].id, "submenu-parent-with-attrs", "Correct id"); + + const subMenuItems = menus[0].querySelectorAll("menupopup > menuitem"); + is(subMenuItems.length, 1, "Correct number of submenu items"); + is(subMenuItems[0].getAttribute("label"), "Submenu item", "Correct label"); + + await once(menu, "open"); + const closed = once(menu, "close"); + + // The following section tests keyboard navigation of the context menus. + // This doesn't work on macOS when native context menus are enabled. + if (Services.prefs.getBoolPref("widget.macos.native-context-menus", false)) { + info("Using openMenu semantics because of macOS native context menus."); + let shown = once(menus[0], "popupshown"); + menus[0].openMenu(true); + await shown; + + const hidden = once(menus[0], "popuphidden"); + menus[0].openMenu(false); + await hidden; + + shown = once(menus[0], "popupshown"); + menus[0].openMenu(true); + await shown; + } else { + info("Using keyboard navigation to open, close, and reopen the submenu"); + let shown = once(menus[0], "popupshown"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_ArrowRight"); + await shown; + + const hidden = once(menus[0], "popuphidden"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + await hidden; + + shown = once(menus[0], "popupshown"); + EventUtils.synthesizeKey("KEY_ArrowRight"); + await shown; + } + + info("Clicking the submenu item"); + const subMenu = subMenuItems[0].closest("menupopup"); + subMenu.activateItem(subMenuItems[0]); + + await closed; + ok(clickFired, "Click has fired"); +} diff --git a/devtools/client/framework/test/browser_new_activation_workflow.js b/devtools/client/framework/test/browser_new_activation_workflow.js new file mode 100644 index 0000000000..583e6d7ca8 --- /dev/null +++ b/devtools/client/framework/test/browser_new_activation_workflow.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests devtools API + +var toolbox; + +function test() { + addTab("about:blank").then(async function () { + loadWebConsole().then(function () { + console.log("loaded"); + }); + }); +} + +function loadWebConsole() { + ok(gDevTools, "gDevTools exists"); + const tab = gBrowser.selectedTab; + return gDevTools + .showToolboxForTab(tab, { toolId: "webconsole" }) + .then(function (aToolbox) { + toolbox = aToolbox; + checkToolLoading(); + }); +} + +function checkToolLoading() { + is(toolbox.currentToolId, "webconsole", "The web console is selected"); + ok(toolbox.isReady, "toolbox is ready"); + + selectAndCheckById("jsdebugger").then(function () { + selectAndCheckById("styleeditor").then(function () { + testToggle(); + }); + }); +} + +function selectAndCheckById(id) { + return toolbox.selectTool(id).then(function () { + const tab = toolbox.doc.getElementById("toolbox-tab-" + id); + is( + tab.classList.contains("selected"), + true, + "The " + id + " tab is selected" + ); + is( + tab.getAttribute("aria-pressed"), + "true", + "The " + id + " tab is pressed" + ); + }); +} + +function testToggle() { + toolbox.once("destroyed", async () => { + // Cannot reuse a target after it's destroyed. + gDevTools + .showToolboxForTab(gBrowser.selectedTab, { toolId: "styleeditor" }) + .then(function (aToolbox) { + toolbox = aToolbox; + is( + toolbox.currentToolId, + "styleeditor", + "The style editor is selected" + ); + finishUp(); + }); + }); + + toolbox.destroy(); +} + +function finishUp() { + toolbox.destroy().then(function () { + toolbox = null; + gBrowser.removeCurrentTab(); + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_source_map-01.js b/devtools/client/framework/test/browser_source_map-01.js new file mode 100644 index 0000000000..373eaebf77 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-01.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the SourceMapService updates generated sources when source maps + * are subsequently found. Also checks when no column is provided, and + * when tagging an already source mapped location initially. + */ + +// 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(/this\.worker is null/); +PromiseTestUtils.allowMatchingRejectionsGlobally(/Component not initialized/); + +// Empty page +const PAGE_URL = `${URL_ROOT_SSL}doc_empty-tab-01.html`; +const JS_URL = `${URL_ROOT_SSL}code_binary_search.js`; +const COFFEE_URL = `${URL_ROOT_SSL}code_binary_search.coffee`; + +add_task(async function () { + const toolbox = await openNewTabAndToolbox(PAGE_URL, "jsdebugger"); + const service = toolbox.sourceMapURLService; + + // Inject JS script + const sourceSeen = waitForSourceLoad(toolbox, JS_URL); + await createScript(JS_URL); + await sourceSeen; + + const loc1 = { url: JS_URL, line: 6 }; + const newLoc1 = await new Promise(r => + service.subscribeByURL(loc1.url, loc1.line, 4, r) + ); + checkLoc1(loc1, newLoc1); + + const loc2 = { url: JS_URL, line: 8, column: 3 }; + const newLoc2 = await new Promise(r => + service.subscribeByURL(loc2.url, loc2.line, loc2.column, r) + ); + checkLoc2(loc2, newLoc2); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); + finish(); +}); + +function checkLoc1(oldLoc, newLoc) { + is(oldLoc.line, 6, "Correct line for JS:6"); + is(oldLoc.column, undefined, "Correct column for JS:6"); + is(oldLoc.url, JS_URL, "Correct url for JS:6"); + is(newLoc.line, 4, "Correct line for JS:6 -> COFFEE"); + is( + newLoc.column, + 2, + "Correct column for JS:6 -> COFFEE -- handles falsy column entries" + ); + is(newLoc.url, COFFEE_URL, "Correct url for JS:6 -> COFFEE"); +} + +function checkLoc2(oldLoc, newLoc) { + is(oldLoc.line, 8, "Correct line for JS:8:3"); + is(oldLoc.column, 3, "Correct column for JS:8:3"); + is(oldLoc.url, JS_URL, "Correct url for JS:8:3"); + is(newLoc.line, 6, "Correct line for JS:8:3 -> COFFEE"); + is(newLoc.column, 10, "Correct column for JS:8:3 -> COFFEE"); + is(newLoc.url, COFFEE_URL, "Correct url for JS:8:3 -> COFFEE"); +} diff --git a/devtools/client/framework/test/browser_source_map-absolute.js b/devtools/client/framework/test/browser_source_map-absolute.js new file mode 100644 index 0000000000..206cbde944 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-absolute.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that an absolute sourceRoot works. + +"use strict"; + +// 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(/this\.worker is null/); + +// Empty page +const PAGE_URL = `${URL_ROOT_SSL}doc_empty-tab-01.html`; +const JS_URL = `${URL_ROOT_SSL}code_binary_search_absolute.js`; +const ORIGINAL_URL = `${URL_ROOT_SSL}code_binary_search.coffee`; + +add_task(async function () { + const toolbox = await openNewTabAndToolbox(PAGE_URL, "jsdebugger"); + const service = toolbox.sourceMapURLService; + + // Inject JS script + const sourceSeen = waitForSourceLoad(toolbox, JS_URL); + await createScript(JS_URL); + await sourceSeen; + + info(`checking original location for ${JS_URL}:6`); + const newLoc = await new Promise(r => + service.subscribeByURL(JS_URL, 6, 4, r) + ); + + is(newLoc.url, ORIGINAL_URL, "check mapped URL"); + is(newLoc.line, 4, "check mapped line number"); +}); diff --git a/devtools/client/framework/test/browser_source_map-cross-domain.js b/devtools/client/framework/test/browser_source_map-cross-domain.js new file mode 100644 index 0000000000..77fb381260 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-cross-domain.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that the source map service can fetch a source map from a +// different domain. + +"use strict"; + +const JS_URL = URL_ROOT + "code_bundle_cross_domain.js"; + +const PAGE_URL = `data:text/html, +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Empty test page to test cross domain source map</title> + </head> + + <body> + <script src="${JS_URL}"></script> + </body> + +</html>`; + +const ORIGINAL_URL = "webpack:///code_cross_domain.js"; + +const GENERATED_LINE = 82; +const ORIGINAL_LINE = 12; + +add_task(async function () { + const toolbox = await openNewTabAndToolbox(PAGE_URL, "webconsole"); + const service = toolbox.sourceMapURLService; + + info(`checking original location for ${JS_URL}:${GENERATED_LINE}`); + const newLoc = await new Promise(r => + service.subscribeByURL(JS_URL, GENERATED_LINE, undefined, r) + ); + is(newLoc.url, ORIGINAL_URL, "check mapped URL"); + is(newLoc.line, ORIGINAL_LINE, "check mapped line number"); +}); diff --git a/devtools/client/framework/test/browser_source_map-init.js b/devtools/client/framework/test/browser_source_map-init.js new file mode 100644 index 0000000000..60a3e4672a --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-init.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that the source map service initializes properly when source +// actors have already been created. Regression test for bug 1391768. + +"use strict"; + +const JS_URL = URL_ROOT_SSL + "code_bundle_no_race.js"; + +const PAGE_URL = `data:text/html, +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Empty test page to test race case</title> + </head> + + <body> + <script src="${JS_URL}"></script> + </body> + +</html>`; + +const ORIGINAL_URL = "webpack:///code_no_race.js"; + +const GENERATED_LINE = 84; +const ORIGINAL_LINE = 11; + +add_task(async function () { + // Opening the debugger causes the source actors to be created. + const toolbox = await openNewTabAndToolbox(PAGE_URL, "jsdebugger"); + // In bug 1391768, when the sourceMapURLService was created, it was + // ignoring any source actors that already existed, leading to + // source-mapping failures for those. + const service = toolbox.sourceMapURLService; + + info(`checking original location for ${JS_URL}:${GENERATED_LINE}`); + const newLoc = await new Promise(r => + service.subscribeByURL(JS_URL, GENERATED_LINE, undefined, r) + ); + is(newLoc.url, ORIGINAL_URL, "check mapped URL"); + is(newLoc.line, ORIGINAL_LINE, "check mapped line number"); + + // See Bug 1637793 and Bug 1621337. + // Ideally the debugger should only resolve when the worker targets have been + // retrieved, which should be fixed by Bug 1621337 or a followup. + info("Wait for all pending requests to settle on the DevToolsClient"); + await toolbox.commands.client.waitForRequestsToSettle(); +}); diff --git a/devtools/client/framework/test/browser_source_map-inline.js b/devtools/client/framework/test/browser_source_map-inline.js new file mode 100644 index 0000000000..4e5f8c7fff --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-inline.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that inline source maps work. + +"use strict"; + +// 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(/this\.worker is null/); +PromiseTestUtils.allowMatchingRejectionsGlobally(/Component not initialized/); + +const TEST_ROOT = "https://example.com/browser/devtools/client/framework/test/"; +// Empty page +const PAGE_URL = `${TEST_ROOT}doc_empty-tab-01.html`; +const JS_URL = `${TEST_ROOT}code_inline_bundle.js`; +const ORIGINAL_URL = "webpack:///code_inline_original.js"; + +add_task(async function () { + const toolbox = await openNewTabAndToolbox(PAGE_URL, "jsdebugger"); + const service = toolbox.sourceMapURLService; + + // Inject JS script + const sourceSeen = waitForSourceLoad(toolbox, JS_URL); + await createScript(JS_URL); + await sourceSeen; + + info(`checking original location for ${JS_URL}:84`); + const newLoc = await new Promise(r => + service.subscribeByURL(JS_URL, 84, undefined, r) + ); + + is(newLoc.url, ORIGINAL_URL, "check mapped URL"); + is(newLoc.line, 11, "check mapped line number"); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); + finish(); +}); diff --git a/devtools/client/framework/test/browser_source_map-late-script.js b/devtools/client/framework/test/browser_source_map-late-script.js new file mode 100644 index 0000000000..f11d530db1 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-late-script.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that you can subscribe to notifications on a source before it has loaded. + +"use strict"; + +const PAGE_URL = `${URL_ROOT_SSL}doc_empty-tab-01.html`; +const JS_URL = URL_ROOT_SSL + "code_bundle_late_script.js"; + +const ORIGINAL_URL = "webpack:///code_late_script.js"; + +const GENERATED_LINE = 107; +const ORIGINAL_LINE = 11; + +add_task(async function () { + // Start with the empty page, then navigate, so that we can properly + // listen for new sources arriving. + const toolbox = await openNewTabAndToolbox(PAGE_URL, "webconsole"); + const service = toolbox.sourceMapURLService; + + const scriptMapped = new Promise(resolve => { + let count = 0; + service.subscribeByURL( + JS_URL, + GENERATED_LINE, + undefined, + originalLocation => { + if (count === 0) { + resolve(originalLocation); + } + count += 1; + + return () => {}; + } + ); + }); + + // Inject JS script + const sourceSeen = waitForSourceLoad(toolbox, JS_URL); + await createScript(JS_URL); + await sourceSeen; + + // Ensure that the URL service fired an event about the location loading. + const { url, line } = await scriptMapped; + is(url, ORIGINAL_URL, "check mapped URL"); + is(line, ORIGINAL_LINE, "check mapped line number"); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); + finish(); +}); diff --git a/devtools/client/framework/test/browser_source_map-no-race.js b/devtools/client/framework/test/browser_source_map-no-race.js new file mode 100644 index 0000000000..23751f7bc8 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-no-race.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that the source map service doesn't race against source +// reporting. + +"use strict"; + +const JS_URL = URL_ROOT + "code_bundle_no_race.js"; + +const PAGE_URL = `data:text/html, +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Empty test page to test race case</title> + </head> + + <body> + <script src="${JS_URL}"></script> + </body> + +</html>`; + +const ORIGINAL_URL = "webpack:///code_no_race.js"; + +const GENERATED_LINE = 84; +const ORIGINAL_LINE = 11; + +add_task(async function () { + // Start with the empty page, then navigate, so that we can properly + // listen for new sources arriving. + const toolbox = await openNewTabAndToolbox(PAGE_URL, "webconsole"); + const service = toolbox.sourceMapURLService; + + info(`checking original location for ${JS_URL}:${GENERATED_LINE}`); + const newLoc = await new Promise(r => + service.subscribeByURL(JS_URL, GENERATED_LINE, undefined, r) + ); + is(newLoc.url, ORIGINAL_URL, "check mapped URL"); + is(newLoc.line, ORIGINAL_LINE, "check mapped line number"); +}); diff --git a/devtools/client/framework/test/browser_source_map-pub-sub.js b/devtools/client/framework/test/browser_source_map-pub-sub.js new file mode 100644 index 0000000000..c7f69c91c7 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-pub-sub.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that the source map service subscribe mechanism work as expected. + +"use strict"; + +const JS_URL = URL_ROOT + "code_bundle_no_race.js"; + +const PAGE_URL = `data:text/html, +<!doctype html> +<html> + <head> + <meta charset="utf-8"/> + </head> + <body> + <script src="${JS_URL}"></script> + </body> +</html>`; + +const ORIGINAL_URL = "webpack:///code_no_race.js"; + +const SOURCE_MAP_PREF = "devtools.source-map.client-service.enabled"; + +const GENERATED_LINE = 84; +const ORIGINAL_LINE = 11; + +add_task(async function () { + // Push a pref env so any changes will be reset at the end of the test. + await SpecialPowers.pushPrefEnv({}); + + // Opening the debugger causes the source actors to be created. + const toolbox = await openNewTabAndToolbox(PAGE_URL, "jsdebugger"); + const service = toolbox.sourceMapURLService; + + const cbCalls = []; + const cb = originalLocation => cbCalls.push(originalLocation); + const expectedArg = { url: ORIGINAL_URL, line: ORIGINAL_LINE, column: 0 }; + + // Wait for the sources to fully populate so that waitForSubscriptionsToSettle + // can be guaranteed that all actions have been queued. + await service._ensureAllSourcesPopulated(); + + const unsubscribe1 = service.subscribeByURL(JS_URL, GENERATED_LINE, 1, cb); + + // Wait for the query to finish and populate so that all of the later + // logic with this position will run synchronously, and the subscribe has run. + for (const map of service._mapsById.values()) { + for (const query of map.queries.values()) { + await query.action; + } + } + + is( + cbCalls.length, + 1, + "The callback function is called directly when subscribing" + ); + Assert.deepEqual( + cbCalls[0], + expectedArg, + "callback called with expected arguments" + ); + + const unsubscribe2 = service.subscribeByURL(JS_URL, GENERATED_LINE, 1, cb); + is(cbCalls.length, 2, "Subscribing to the same location twice works"); + Assert.deepEqual( + cbCalls[1], + expectedArg, + "callback called with expected arguments" + ); + + info("Manually call the dispatcher to ensure subscribers are called"); + Services.prefs.setBoolPref(SOURCE_MAP_PREF, false); + is(cbCalls.length, 4, "both subscribers were called"); + Assert.deepEqual(cbCalls[2], null, "callback called with expected arguments"); + Assert.deepEqual( + cbCalls[2], + cbCalls[3], + "callbacks were passed the same arguments" + ); + + info("Check unsubscribe functions"); + unsubscribe1(); + Services.prefs.setBoolPref(SOURCE_MAP_PREF, true); + is(cbCalls.length, 5, "Only remainer subscriber callback was called"); + Assert.deepEqual( + cbCalls[4], + expectedArg, + "callback called with expected arguments" + ); + + unsubscribe2(); + Services.prefs.setBoolPref(SOURCE_MAP_PREF, false); + Services.prefs.setBoolPref(SOURCE_MAP_PREF, true); + is(cbCalls.length, 5, "No callbacks were called"); +}); diff --git a/devtools/client/framework/test/browser_source_map-reload.js b/devtools/client/framework/test/browser_source_map-reload.js new file mode 100644 index 0000000000..13902062a7 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-reload.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that reloading re-reads the source maps. + +"use strict"; +const INITIAL_URL = + "data:text/html,<!doctype html>html><head><meta charset='utf-8'/><title>Empty test page 1</title></head><body></body></html>"; +const ORIGINAL_URL_1 = "webpack://code-reload/v1/code_reload_1.js"; +const ORIGINAL_URL_2 = "webpack://code-reload/v2/code_reload_2.js"; + +const GENERATED_LINE = 13; +const ORIGINAL_LINE = 7; + +const testServer = createVersionizedHttpTestServer("reload"); + +const PAGE_URL = testServer.urlFor("doc_reload.html"); +const JS_URL = testServer.urlFor("code_bundle_reload.js"); + +add_task(async function () { + // Start with the empty page, then navigate, so that we can properly + // listen for new sources arriving. + const toolbox = await openNewTabAndToolbox(INITIAL_URL, "webconsole"); + const service = toolbox.sourceMapURLService; + + let sourceSeen = waitForSourceLoad(toolbox, JS_URL); + await navigateTo(PAGE_URL); + await sourceSeen; + + info(`checking original location for ${JS_URL}:${GENERATED_LINE}`); + let newLoc = await new Promise(r => + service.subscribeByURL(JS_URL, GENERATED_LINE, undefined, r) + ); + + is(newLoc.url, ORIGINAL_URL_1, "check mapped URL"); + is(newLoc.line, ORIGINAL_LINE, "check mapped line number"); + + testServer.switchToNextVersion(); + + // Reload the page. A different source file will be loaded. + sourceSeen = waitForSourceLoad(toolbox, JS_URL); + await reloadBrowser(); + await sourceSeen; + + info( + `checking post-reload original location for ${JS_URL}:${GENERATED_LINE}` + ); + newLoc = await new Promise(r => + service.subscribeByURL(JS_URL, GENERATED_LINE, undefined, r) + ); + is(newLoc.url, ORIGINAL_URL_2, "check post-reload mapped URL"); + is(newLoc.line, ORIGINAL_LINE, "check post-reload mapped line number"); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_tab_commands_factory.js b/devtools/client/framework/test/browser_tab_commands_factory.js new file mode 100644 index 0000000000..d8f2f44ca8 --- /dev/null +++ b/devtools/client/framework/test/browser_tab_commands_factory.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test LocalTabCommandsFactory + +const { + LocalTabCommandsFactory, +} = require("resource://devtools/client/framework/local-tab-commands-factory.js"); + +add_task(async function () { + await testTabDescriptorWithURL("data:text/html;charset=utf-8,foo"); + + // Bug 1699497: Also test against a page in the parent process + // which can hit some race with frame-connector's frame scripts. + await testTabDescriptorWithURL("about:robots"); +}); + +async function testTabDescriptorWithURL(url) { + info(`Test TabDescriptor against url ${url}\n`); + const tab = await addTab(url); + + const commands = await LocalTabCommandsFactory.createCommandsForTab(tab); + is( + commands.descriptorFront.localTab, + tab, + "TabDescriptor's localTab is set correctly" + ); + + info( + "Calling a second time createCommandsForTab with the same tab, will return the same commands" + ); + const secondCommands = await LocalTabCommandsFactory.createCommandsForTab( + tab + ); + is(commands, secondCommands, "second commands is the same"); + + // We have to involve TargetCommand in order to have a function TabDescriptor.getTarget. + await commands.targetCommand.startListening(); + + info("Wait for descriptor's target"); + const target = await commands.descriptorFront.getTarget(); + + info("Call any method to ensure that each target works"); + await target.logInPage("foo"); + + info("Destroy the command"); + await commands.destroy(); + + gBrowser.removeCurrentTab(); +} diff --git a/devtools/client/framework/test/browser_tab_descriptor_fission.js b/devtools/client/framework/test/browser_tab_descriptor_fission.js new file mode 100644 index 0000000000..bf15f01293 --- /dev/null +++ b/devtools/client/framework/test/browser_tab_descriptor_fission.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that tab descriptor survives after the page navigates and changes + * process. + */ + +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?html=<div id=org>org"; + +add_task(async function () { + const tab = await addTab(EXAMPLE_COM_URI); + const toolbox = await gDevTools.showToolboxForTab(tab); + const target = toolbox.target; + const client = toolbox.commands.client; + + info("Retrieve the initial list of tab descriptors"); + const tabDescriptors = await client.mainRoot.listTabs(); + const tabDescriptor = tabDescriptors.find( + d => decodeURIComponent(d.url) === EXAMPLE_COM_URI + ); + ok(tabDescriptor, "Should have a descriptor actor for the tab"); + + info("Retrieve the target corresponding to the TabDescriptor"); + const comTabTarget = await tabDescriptor.getTarget(); + is( + target, + comTabTarget, + "The toolbox target is also the target associated with the tab descriptor" + ); + + await navigateTo(EXAMPLE_ORG_URI); + + info("Call list tabs again to update the tab descriptor forms"); + await client.mainRoot.listTabs(); + + is( + decodeURIComponent(tabDescriptor.url), + EXAMPLE_ORG_URI, + "The existing descriptor now points to the new URI" + ); + + const newTarget = toolbox.target; + + is( + comTabTarget.actorID, + null, + "With Fission or server side target switching, example.com target front is destroyed" + ); + Assert.notEqual( + comTabTarget, + newTarget, + "With Fission or server side target switching, a new target was created for example.org" + ); + + const onDescriptorDestroyed = tabDescriptor.once("descriptor-destroyed"); + + await removeTab(tab); + + info("Wait for descriptor destroyed event"); + await onDescriptorDestroyed; + ok(tabDescriptor.isDestroyed(), "the descriptor front is really destroyed"); +}); diff --git a/devtools/client/framework/test/browser_target_cached-front.js b/devtools/client/framework/test/browser_target_cached-front.js new file mode 100644 index 0000000000..43156cbded --- /dev/null +++ b/devtools/client/framework/test/browser_target_cached-front.js @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + const target = await createAndAttachTargetForTab(gBrowser.selectedTab); + + info("Cached front when getFront has not been called"); + let getCachedFront = target.getCachedFront("accessibility"); + ok(!getCachedFront, "no front exists"); + + info("Cached front when getFront has been called but has not finished"); + const asyncFront = target.getFront("accessibility"); + getCachedFront = target.getCachedFront("accessibility"); + ok(!getCachedFront, "no front exists"); + + info("Cached front when getFront has been called and has finished"); + const front = await asyncFront; + getCachedFront = target.getCachedFront("accessibility"); + is(getCachedFront, front, "front is the same as async front"); +}); diff --git a/devtools/client/framework/test/browser_target_cached-resource.js b/devtools/client/framework/test/browser_target_cached-resource.js new file mode 100644 index 0000000000..b6f53fdee0 --- /dev/null +++ b/devtools/client/framework/test/browser_target_cached-resource.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// The target front holds resources that happend before ResourceCommand addeed listeners. +// Test whether that feature works correctly or not. +const TEST_URI = + "https://example.com/browser/devtools/client/framework/test/doc_cached-resource.html"; +const PARENT_MESSAGE = "Hello from parent"; +const CHILD_MESSAGE = "Hello from child"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/webconsole/test/browser/shared-head.js", + this +); + +add_task(async function () { + info("Open console"); + const tab = await addTab(TEST_URI); + const toolbox = await openToolboxForTab(tab, "webconsole"); + const hud = toolbox.getCurrentPanel().hud; + + info("Check the initial messages"); + ok( + findMessageByType(hud, PARENT_MESSAGE, ".console-api"), + "Message from parent document is in console" + ); + ok( + findMessageByType(hud, CHILD_MESSAGE, ".console-api"), + "Message from child document is in console" + ); + + info("Clear the messages"); + hud.ui.window.document.querySelector(".devtools-clear-icon").click(); + await waitUntil( + () => !findMessageByType(hud, PARENT_MESSAGE, ".console-api") + ); + + info("Reload the browsing page"); + await navigateTo(TEST_URI); + + info("Check the messages after reloading"); + await waitUntil( + () => + findMessageByType(hud, PARENT_MESSAGE, ".console-api") && + findMessageByType(hud, CHILD_MESSAGE, ".console-api") + ); + ok(true, "All messages are shown correctly"); +}); diff --git a/devtools/client/framework/test/browser_target_get-front.js b/devtools/client/framework/test/browser_target_get-front.js new file mode 100644 index 0000000000..9dac79d196 --- /dev/null +++ b/devtools/client/framework/test/browser_target_get-front.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + const tab = await addTab("about:blank"); + const target = await createAndAttachTargetForTab(tab); + + const tab2 = await addTab("about:blank"); + const target2 = await createAndAttachTargetForTab(tab2); + + info("Test the targetFront attribute for the root"); + const { client } = target; + is( + client.mainRoot.targetFront, + null, + "got null from the targetFront attribute for the root" + ); + is( + client.mainRoot.parentFront, + null, + "got null from the parentFront attribute for the root" + ); + + info("Test getting a front twice"); + const getAccessibilityFront = await target.getFront("accessibility"); + const getAccessibilityFront2 = await target.getFront("accessibility"); + is( + getAccessibilityFront, + getAccessibilityFront2, + "got the same front when calling getFront twice" + ); + is( + getAccessibilityFront.targetFront, + target, + "got the correct targetFront attribute from the front" + ); + is( + getAccessibilityFront2.targetFront, + target, + "got the correct targetFront attribute from the front" + ); + is( + getAccessibilityFront.parentFront, + target, + "got the correct parentFront attribute from the front" + ); + is( + getAccessibilityFront2.parentFront, + target, + "got the correct parentFront attribute from the front" + ); + + info("Test getting a front on different targets"); + const target1Front = await target.getFront("accessibility"); + const target2Front = await target2.getFront("accessibility"); + is( + target1Front !== target2Front, + true, + "got different fronts when calling getFront on different targets" + ); + is( + target1Front.targetFront !== target2Front.targetFront, + true, + "got different targetFront from different fronts from different targets" + ); + is( + target2Front.targetFront, + target2, + "got the correct targetFront attribute from the front" + ); + + info("Test async front retrieval"); + // use two fronts that are initialized one after the other. + const asyncFront1 = target.getFront("accessibility"); + const asyncFront2 = target.getFront("accessibility"); + + info("waiting on async fronts returns a real front"); + const awaitedAsyncFront1 = await asyncFront1; + const awaitedAsyncFront2 = await asyncFront2; + is( + awaitedAsyncFront1, + awaitedAsyncFront2, + "got the same front when requesting the front first async then sync" + ); + await target.destroy(); + await target2.destroy(); + + info("destroying a front immediately is possible"); + await testDestroy(); +}); + +async function testDestroy() { + // initialize a clean target + const tab = await addTab("about:blank"); + const target = await createAndAttachTargetForTab(tab); + + // do not wait for the front to finish loading + target.getFront("accessibility"); + + try { + await target.destroy(); + ok( + true, + "calling destroy on an async front instantiated with getFront does not throw" + ); + } catch (e) { + ok( + false, + "calling destroy on an async front instantiated with getFront does not throw" + ); + } +} diff --git a/devtools/client/framework/test/browser_target_listeners.js b/devtools/client/framework/test/browser_target_listeners.js new file mode 100644 index 0000000000..942ac3bed1 --- /dev/null +++ b/devtools/client/framework/test/browser_target_listeners.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + const target = await createAndAttachTargetForTab(gBrowser.selectedTab); + + info("Test applying watchFronts to a front that will be created"); + const promise = new Promise(resolve => { + target.watchFronts("accessibility", resolve); + }); + const getFrontFront = await target.getFront("accessibility"); + const watchFrontsFront = await promise; + is( + getFrontFront, + watchFrontsFront, + "got the front instantiated in the future and it's the same" + ); + + info("Test applying watchFronts to an existing front"); + await new Promise(resolve => { + target.watchFronts("accessibility", front => { + is( + front, + getFrontFront, + "got the already instantiated front and it's the same" + ); + resolve(); + }); + }); +}); diff --git a/devtools/client/framework/test/browser_target_loading.js b/devtools/client/framework/test/browser_target_loading.js new file mode 100644 index 0000000000..fe579bdaa9 --- /dev/null +++ b/devtools/client/framework/test/browser_target_loading.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that toolbox can be opened right after a tab is added, while the document +// is still loading. + +add_task(async function testOpenToolboxOnLoadingDocument() { + const TEST_URI = + `https://example.com/document-builder.sjs?` + + `html=Test<script>console.log("page loaded")</script>`; + + // ⚠️ Note that we don't await for `addTab` here, as we want to open the toolbox just + // after the tab is addded, with the document still loading. + info("Add tab…"); + const onTabAdded = addTab(TEST_URI); + const tab = gBrowser.selectedTab; + info("…and open the toolbox right away"); + const onToolboxShown = gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + }); + + await onTabAdded; + ok(true, "The tab as done loading"); + + const toolbox = await onToolboxShown; + ok(true, "The toolbox is shown"); + + info("Check that the console opened and has the message from the page"); + const { hud } = toolbox.getPanel("webconsole"); + await waitFor(() => + Array.from(hud.ui.window.document.querySelectorAll(".message-body")).some( + el => el.innerText.includes("page loaded") + ) + ); + ok(true, "The console opened with the expected content"); +}); diff --git a/devtools/client/framework/test/browser_target_parents.js b/devtools/client/framework/test/browser_target_parents.js new file mode 100644 index 0000000000..795219abef --- /dev/null +++ b/devtools/client/framework/test/browser_target_parents.js @@ -0,0 +1,189 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test a given Target's parentFront attribute returns the correct parent front. + +const { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +const { + createCommandsDictionary, +} = require("resource://devtools/shared/commands/index.js"); + +const TEST_URL = `data:text/html;charset=utf-8,<div id="test"></div>`; + +// Test against Tab targets +add_task(async function () { + const tab = await addTab(TEST_URL); + + const client = await setupDebuggerClient(); + const mainRoot = client.mainRoot; + + const tabDescriptors = await mainRoot.listTabs(); + + const concurrentCommands = []; + for (const descriptor of tabDescriptors) { + concurrentCommands.push( + (async () => { + const commands = await createCommandsDictionary(descriptor); + // Descriptor's getTarget will only work if the TargetCommand watches for the first top target + await commands.targetCommand.startListening(); + })() + ); + } + info("Instantiate all tab's commands and initialize their TargetCommand"); + await Promise.all(concurrentCommands); + + await testGetTargetWithConcurrentCalls(tabDescriptors, tabTarget => { + // We only call BrowsingContextTargetFront.attach and not TargetMixin.attachAndInitThread. + // So very few things are done. + return !!tabTarget.targetForm?.traits; + }); + + await client.close(); + await removeTab(tab); +}); + +// Test against Process targets +add_task(async function () { + const client = await setupDebuggerClient(); + const mainRoot = client.mainRoot; + + const processes = await mainRoot.listProcesses(); + + // Assert that concurrent calls to getTarget resolves the same target and that it is already attached + // With that, we were chasing a precise race, where a second call to ProcessDescriptor.getTarget() + // happens between the instantiation of ContentProcessTarget and its call to attach() from getTarget + // function. + await testGetTargetWithConcurrentCalls(processes, processTarget => { + // We only call ContentProcessTargetFront.attach and not TargetMixin.attachAndInitThread. + // So nothing is done for content process targets. + return true; + }); + + await client.close(); +}); + +// Test against Webextension targets +add_task(async function () { + const client = await setupDebuggerClient(); + + const mainRoot = client.mainRoot; + + const addons = await mainRoot.listAddons(); + await Promise.all( + // some extensions, such as themes, are not debuggable. Filter those out + // before trying to connect. + addons + .filter(a => a.debuggable) + .map(async addonDescriptorFront => { + const addonFront = await addonDescriptorFront.getTarget(); + ok(addonFront, "Got the addon target"); + }) + ); + + await client.close(); +}); + +// Test against worker targets on parent process +add_task(async function () { + const client = await setupDebuggerClient(); + + const mainRoot = client.mainRoot; + + const { workers } = await mainRoot.listWorkers(); + + ok(!!workers.length, "list workers returned a non-empty list of workers"); + + for (const workerDescriptorFront of workers) { + let targetFront; + try { + targetFront = await workerDescriptorFront.getTarget(); + } catch (e) { + // Ignore race condition where we are trying to connect to a worker + // related to a previous test which is being destroyed. + if ( + e.message.includes("nsIWorkerDebugger.initialize") || + workerDescriptorFront.isDestroyed() || + !workerDescriptorFront.name + ) { + info("Failed to connect to " + workerDescriptorFront.url); + continue; + } + throw e; + } + // Bug 1767760: name might be null on some worker which are probably initializing or destroying. + if (!workerDescriptorFront.name) { + info("Failed to connect to " + workerDescriptorFront.url); + continue; + } + + is( + workerDescriptorFront, + targetFront, + "For now, worker descriptors and targets are the same object (see bug 1667404)" + ); + // Check that accessing descriptor#name getter doesn't throw (See Bug 1714974). + ok( + workerDescriptorFront.name.includes(".js") || + workerDescriptorFront.name.includes(".mjs"), + `worker descriptor front holds the worker file name (${workerDescriptorFront.name})` + ); + is( + workerDescriptorFront.isWorkerDescriptor, + true, + "isWorkerDescriptor is true" + ); + } + + await client.close(); +}); + +async function setupDebuggerClient() { + // Instantiate a minimal server + DevToolsServer.init(); + DevToolsServer.allowChromeProcess = true; + if (!DevToolsServer.createRootActor) { + DevToolsServer.registerAllActors(); + } + const transport = DevToolsServer.connectPipe(); + const client = new DevToolsClient(transport); + await client.connect(); + return client; +} + +async function testGetTargetWithConcurrentCalls(descriptors, isTargetAttached) { + // Assert that concurrent calls to getTarget resolves the same target and that it is already attached + await Promise.all( + descriptors.map(async descriptor => { + const promises = []; + const concurrentCalls = 10; + for (let i = 0; i < concurrentCalls; i++) { + const targetPromise = descriptor.getTarget(); + // Every odd runs, wait for a tick to introduce some more randomness + if (i % 2 == 0) { + await wait(0); + } + promises.push( + targetPromise.then(target => { + ok(isTargetAttached(target), "The target is attached"); + return target; + }) + ); + } + const targets = await Promise.all(promises); + for (let i = 1; i < concurrentCalls; i++) { + is( + targets[0], + targets[i], + "All the targets returned by concurrent calls to getTarget are the same" + ); + } + }) + ); +} diff --git a/devtools/client/framework/test/browser_target_remote.js b/devtools/client/framework/test/browser_target_remote.js new file mode 100644 index 0000000000..272797d626 --- /dev/null +++ b/devtools/client/framework/test/browser_target_remote.js @@ -0,0 +1,15 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Ensure target is closed if client is closed directly +function test() { + waitForExplicitFinish(); + + getParentProcessActors((client, target) => { + target.on("target-destroyed", () => { + ok(true, "Target was destroyed"); + finish(); + }); + client.close(); + }); +} diff --git a/devtools/client/framework/test/browser_target_server_compartment.js b/devtools/client/framework/test/browser_target_server_compartment.js new file mode 100644 index 0000000000..c0bd8e56f0 --- /dev/null +++ b/devtools/client/framework/test/browser_target_server_compartment.js @@ -0,0 +1,126 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Bug 1515290 - Ensure that DevToolsServer runs in its own compartment when debugging +// chrome context. If not, Debugger API's addGlobal will throw when trying to attach +// to chrome scripts as debugger actor's module and the chrome script will be in the same +// compartment. Debugger and debuggee can't be running in the same compartment. + +const CHROME_PAGE = + "chrome://mochitests/content/browser/devtools/client/framework/" + + "test/test_chrome_page.html"; + +add_task(async function () { + await testChromeTab(); + await testMainProcess(); +}); + +// Test that Tab Target can debug chrome pages +async function testChromeTab() { + const tab = await addTab(CHROME_PAGE); + const browser = tab.linkedBrowser; + ok(!browser.isRemoteBrowser, "chrome page is not remote"); + ok( + browser.contentWindow.document.nodePrincipal.isSystemPrincipal, + "chrome page is a privileged document" + ); + + const onThreadActorInstantiated = new Promise(resolve => { + const observe = function (subject, topic, data) { + if (topic === "devtools-thread-ready") { + Services.obs.removeObserver(observe, "devtools-thread-ready"); + const threadActor = subject.wrappedJSObject; + resolve(threadActor); + } + }; + Services.obs.addObserver(observe, "devtools-thread-ready"); + }); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const sources = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.SOURCE], + { + onAvailable(resources) { + sources.push(...resources); + }, + } + ); + ok( + sources.find(s => s.url == CHROME_PAGE), + "The thread actor is able to attach to the chrome page and its sources" + ); + + const threadActor = await onThreadActorInstantiated; + const serverGlobal = Cu.getGlobalForObject(threadActor); + isnot( + loader.id, + serverGlobal.loader.id, + "The actors are loaded in a distinct loader in order for the actors to use its very own compartment" + ); + + const onDedicatedLoaderDestroy = new Promise(resolve => { + const observe = function (subject, topic, data) { + if (topic === "devtools:loader:destroy") { + Services.obs.removeObserver(observe, "devtools:loader:destroy"); + resolve(); + } + }; + Services.obs.addObserver(observe, "devtools:loader:destroy"); + }); + + await commands.destroy(); + + // Wait for the dedicated loader used for DevToolsServer to be destroyed + // in order to prevent leak reports on try + await onDedicatedLoaderDestroy; +} + +// Test that Main process Target can debug chrome scripts +async function testMainProcess() { + const onThreadActorInstantiated = new Promise(resolve => { + const observe = function (subject, topic, data) { + if (topic === "devtools-thread-ready") { + Services.obs.removeObserver(observe, "devtools-thread-ready"); + const threadActor = subject.wrappedJSObject; + resolve(threadActor); + } + }; + Services.obs.addObserver(observe, "devtools-thread-ready"); + }); + + const client = await CommandsFactory.spawnClientToDebugSystemPrincipal(); + const commands = await CommandsFactory.forMainProcess({ client }); + await commands.targetCommand.startListening(); + + const sources = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.SOURCE], + { + onAvailable(resources) { + sources.push(...resources); + }, + } + ); + ok( + sources.find( + s => s.url == "resource://devtools/client/framework/devtools.js" + ), + "The thread actor is able to attach to the chrome script, like client modules" + ); + + const threadActor = await onThreadActorInstantiated; + const serverGlobal = Cu.getGlobalForObject(threadActor); + isnot( + loader.id, + serverGlobal.loader.id, + "The actors are loaded in a distinct loader in order for the actors to use its very own compartment" + ); + + // As this target is remote (i.e. isn't a local tab) calling Target.destroy won't close + // the client. So do it manually here in order to ensure cleaning up the DevToolsServer + // spawn for this main process actor. + await commands.destroy(); +} diff --git a/devtools/client/framework/test/browser_target_support.js b/devtools/client/framework/test/browser_target_support.js new file mode 100644 index 0000000000..ff87b9fad4 --- /dev/null +++ b/devtools/client/framework/test/browser_target_support.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test support methods on Target, such as `hasActor` and `getTrait`. + +async function testTarget(client, target) { + is( + target.hasActor("inspector"), + true, + "target.hasActor() true when actor exists." + ); + is( + target.hasActor("notreal"), + false, + "target.hasActor() false when actor does not exist." + ); + + is( + target.getTrait("giddyup"), + undefined, + "target.getTrait() returns undefined when trait does not exist" + ); + + close(target, client); +} + +// Ensure target is closed if client is closed directly +function test() { + waitForExplicitFinish(); + + getParentProcessActors(testTarget); +} + +function close(target, client) { + target.on("target-destroyed", () => { + ok(true, "Target was destroyed"); + finish(); + }); + client.close(); +} diff --git a/devtools/client/framework/test/browser_toolbox_backward_forward_navigation.js b/devtools/client/framework/test/browser_toolbox_backward_forward_navigation.js new file mode 100644 index 0000000000..7350c2601c --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_backward_forward_navigation.js @@ -0,0 +1,188 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// The test can take a while to run +requestLongerTimeout(3); + +const FILENAME = "doc_backward_forward_navigation.html"; +const TEST_URI_ORG = `${URL_ROOT_ORG_SSL}${FILENAME}`; +const TEST_URI_COM = `${URL_ROOT_COM_SSL}${FILENAME}`; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/debugger/test/mochitest/shared-head.js", + this +); + +add_task(async function testMultipleNavigations() { + // Disable bfcache for Fission for now. + // If Fission is disabled, the pref is no-op. + await SpecialPowers.pushPrefEnv({ + set: [["fission.bfcacheInParent", false]], + }); + + info( + "Test that DevTools works fine after multiple backward/forward navigations" + ); + // Don't show the third panel to limit the logs and activity. + await pushPref("devtools.inspector.three-pane-enabled", false); + await pushPref("devtools.inspector.activeSidebar", "ruleview"); + const DATA_URL = `data:text/html,<meta charset=utf8>`; + const tab = await addTab(DATA_URL); + + // Select the debugger so there will be more activity + const toolbox = await openToolboxForTab(tab, "jsdebugger"); + const inspector = await toolbox.selectTool("inspector"); + + info("Navigate to the ORG test page"); + // We don't use `navigateTo` as the page is adding stylesheets and js files which might + // delay the load event indefinitely (and we don't need for anything to be loaded, or + // ready, just to register the initial navigation so we can go back and forth between urls) + let onLocationChange = BrowserTestUtils.waitForLocationChange( + gBrowser, + TEST_URI_ORG + ); + BrowserTestUtils.startLoadingURIString(gBrowser, TEST_URI_ORG); + await onLocationChange; + + info("And then navigate to a different origin"); + onLocationChange = BrowserTestUtils.waitForLocationChange( + gBrowser, + TEST_URI_COM + ); + BrowserTestUtils.startLoadingURIString(gBrowser, TEST_URI_COM); + await onLocationChange; + + info( + "Navigate backward and forward multiple times between the two origins, with different delays" + ); + await navigateBackAndForth(TEST_URI_ORG, TEST_URI_COM); + + // Navigate one last time to a document with less activity so we don't have to deal + // with pending promises when we destroy the toolbox + const onInspectorReloaded = inspector.once("reloaded"); + info("Navigate to final document"); + await navigateTo(`${TEST_URI_ORG}?no-mutation`); + info("Waiting for inspector to reload…"); + await onInspectorReloaded; + info("-> inspector reloaded"); + await checkToolboxState(toolbox); +}); + +add_task(async function testSingleBackAndForthInstantNavigation() { + // Disable bfcache for Fission for now. + // If Fission is disabled, the pref is no-op. + await SpecialPowers.pushPrefEnv({ + set: [["fission.bfcacheInParent", false]], + }); + + info( + "Test that DevTools works fine after navigating backward and forward right after" + ); + + // Don't show the third panel to limit the logs and activity. + await pushPref("devtools.inspector.three-pane-enabled", false); + await pushPref("devtools.inspector.activeSidebar", "ruleview"); + const DATA_URL = `data:text/html,<meta charset=utf8>`; + const tab = await addTab(DATA_URL); + + // Select the debugger so there will be more activity + const toolbox = await openToolboxForTab(tab, "jsdebugger"); + const inspector = await toolbox.selectTool("inspector"); + + info("Navigate to a different origin"); + await navigateTo(TEST_URI_COM); + + info("Then navigate back, and forth immediatly"); + // We can't call goBack and right away goForward as goForward and even the call to navigateTo + // a bit later might be ignored. So we wait at least for the location to change. + await safelyGoBack(DATA_URL); + await safelyGoForward(TEST_URI_COM); + + // Navigate one last time to a document with less activity so we don't have to deal + // with pending promises when we destroy the toolbox + const onInspectorReloaded = inspector.once("reloaded"); + info("Navigate to final document"); + await navigateTo(`${TEST_URI_ORG}?no-mutation`); + info("Waiting for inspector to reload…"); + await onInspectorReloaded; + info("-> inspector reloaded"); + await checkToolboxState(toolbox); +}); + +async function checkToolboxState(toolbox) { + info("Check that the toolbox toolbar is still visible"); + const toolboxTabsEl = toolbox.doc.querySelector(".toolbox-tabs"); + ok(toolboxTabsEl, "Toolbar is still visible"); + + info( + "Check that the markup view is rendered correctly and elements can be selected" + ); + const inspector = await toolbox.selectTool("inspector"); + await waitFor( + () => + inspector.markup && + inspector.markup.win.document.body.innerText.includes( + `<body class="no-mutation">` + ), + `wait for <body class="no-mutation"> to be displayed in the markup view, got: ${inspector.markup?.win.document.body.innerText}`, + 100, + 100 + ); + ok(true, "the markup view is still rendered fine"); + await selectNode("ul.logs", inspector); + ok(true, "Nodes can be selected"); + + info("Check that the debugger has some sources"); + const dbgPanel = await toolbox.selectTool("jsdebugger"); + const dbg = createDebuggerContext(toolbox); + + info(`Wait for ${FILENAME} to be displayed in the debugger source panel`); + const rootNode = await waitFor(() => + dbgPanel.panelWin.document.querySelector(selectors.sourceTreeRootNode) + ); + await expandAllSourceNodes(dbg, rootNode); + const sourcesTreeScriptNode = await waitFor(() => + findSourceNodeWithText(dbg, FILENAME) + ); + + ok( + sourcesTreeScriptNode.innerText.includes(FILENAME), + "The debugger has the expected source" + ); +} + +async function navigateBackAndForth( + expectedUrlAfterBackwardNavigation, + expectedUrlAfterForwardNavigation +) { + const delays = [100, 0, 500]; + for (const delay of delays) { + // For each delays, do 3 back/forth navigations + for (let i = 0; i < 3; i++) { + await safelyGoBack(expectedUrlAfterBackwardNavigation); + await wait(delay); + await safelyGoForward(expectedUrlAfterForwardNavigation); + await wait(delay); + } + } +} + +async function safelyGoBack(expectedUrl) { + const onLocationChange = BrowserTestUtils.waitForLocationChange( + gBrowser, + expectedUrl + ); + gBrowser.goBack(); + await onLocationChange; +} + +async function safelyGoForward(expectedUrl) { + const onLocationChange = BrowserTestUtils.waitForLocationChange( + gBrowser, + expectedUrl + ); + gBrowser.goForward(); + await onLocationChange; +} diff --git a/devtools/client/framework/test/browser_toolbox_browsertoolbox_host.js b/devtools/client/framework/test/browser_toolbox_browsertoolbox_host.js new file mode 100644 index 0000000000..8efb7959ce --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_browsertoolbox_host.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = "data:text/html,test browsertoolbox host"; + +add_task(async function () { + const { + Toolbox, + } = require("resource://devtools/client/framework/toolbox.js"); + + const tab = await addTab(TEST_URL); + const options = { doc: document }; + const toolbox = await gDevTools.showToolboxForTab(tab, { + hostType: Toolbox.HostType.BROWSERTOOLBOX, + hostOptions: options, + }); + + is(toolbox.topWindow, window, "Toolbox is included in browser.xhtml"); + const iframe = document.querySelector( + ".devtools-toolbox-browsertoolbox-iframe" + ); + ok(iframe, "A toolbox iframe was created in the provided document"); + is(toolbox.doc, iframe.contentDocument, "Toolbox is in the custom iframe"); + + await toolbox.destroy(); + iframe.remove(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_contentpage_contextmenu.js b/devtools/client/framework/test/browser_toolbox_contentpage_contextmenu.js new file mode 100644 index 0000000000..63363e4cf3 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_contentpage_contextmenu.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = "data:text/html;charset=utf8,<div>test content context menu</div>"; + +/** + * Check that the DevTools context menu opens without triggering the content + * context menu. See Bug 1591140. + */ +add_task(async function () { + const tab = await addTab(URL); + + info("Test context menu conflict with dom.event.contextmenu.enabled=true"); + await pushPref("dom.event.contextmenu.enabled", true); + await checkConflictWithContentPageMenu(tab); + + info("Test context menu conflict with dom.event.contextmenu.enabled=false"); + await pushPref("dom.event.contextmenu.enabled", false); + await checkConflictWithContentPageMenu(tab); +}); + +async function checkConflictWithContentPageMenu(tab) { + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "inspector", + }); + + info("Check that the content page context menu works as expected"); + const contextMenu = document.getElementById("contentAreaContextMenu"); + is(contextMenu.state, "closed", "Content contextmenu is closed"); + + info("Show the content context menu"); + const awaitPopupShown = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "div", + { + type: "contextmenu", + button: 2, + centered: true, + }, + gBrowser.selectedBrowser + ); + await awaitPopupShown; + is(contextMenu.state, "open", "Content contextmenu is open"); + + info("Hide the content context menu"); + const awaitPopupHidden = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + contextMenu.hidePopup(); + await awaitPopupHidden; + is(contextMenu.state, "closed", "Content contextmenu is closed again"); + + info("Check the DevTools menu opens without opening the content menu"); + const onContextMenuPopup = toolbox.once("menu-open"); + // Use inspector search box for the test, any other element should be ok as + // well. + const inspector = toolbox.getPanel("inspector"); + synthesizeContextMenuEvent(inspector.searchBox); + await onContextMenuPopup; + + const textboxContextMenu = toolbox.getTextBoxContextMenu(); + is(contextMenu.state, "closed", "Content contextmenu is still closed"); + is(textboxContextMenu.state, "open", "Toolbox contextmenu is open"); + + info("Check that the toolbox context menu is closed when pressing ESCAPE"); + const onContextMenuHidden = toolbox.once("menu-close"); + if (Services.prefs.getBoolPref("widget.macos.native-context-menus", false)) { + info("Using hidePopup semantics because of macOS native context menus."); + textboxContextMenu.hidePopup(); + } else { + EventUtils.sendKey("ESCAPE", toolbox.win); + } + await onContextMenuHidden; + is(textboxContextMenu.state, "closed", "Toolbox contextmenu is closed."); + + await toolbox.destroy(); +} diff --git a/devtools/client/framework/test/browser_toolbox_disable_f12.js b/devtools/client/framework/test/browser_toolbox_disable_f12.js new file mode 100644 index 0000000000..df0a755714 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_disable_f12.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + const tab = await addTab( + "https://example.com/document-builder.sjs?html=test" + ); + + info("Enable F12 and check that devtools open"); + await pushPref("devtools.f12_enabled", true); + await assertToolboxOpens(tab, { shouldOpen: true }); + await assertToolboxCloses(tab, { shouldClose: true }); + + info("Disable F12 and check that devtools will not open"); + await pushPref("devtools.f12_enabled", false); + await assertToolboxOpens(tab, { shouldOpen: false }); + + info("Enable F12 again and open devtools"); + await pushPref("devtools.f12_enabled", true); + await assertToolboxOpens(tab, { shouldOpen: true }); + + info("Disable F12 and check F12 no longer closes devtools"); + await pushPref("devtools.f12_enabled", false); + await assertToolboxCloses(tab, { shouldClose: false }); + + info("Enable F12 and close devtools"); + await pushPref("devtools.f12_enabled", true); + await assertToolboxCloses(tab, { shouldClose: true }); + + info("Disable F12 and check other shortcuts still work"); + await pushPref("devtools.f12_enabled", false); + const isMac = Services.appinfo.OS == "Darwin"; + const shortcut = { + key: "i", + options: { accelKey: true, altKey: isMac, shiftKey: !isMac }, + }; + await assertToolboxOpens(tab, { shouldOpen: true, shortcut }); + // Check F12 still doesn't close the toolbox + await assertToolboxCloses(tab, { shouldClose: false }); + await assertToolboxCloses(tab, { shouldClose: true, shortcut }); + + gBrowser.removeTab(tab); +}); + +const assertToolboxCloses = async function (tab, { shortcut, shouldClose }) { + info( + `Use ${ + shortcut ? "shortcut" : "F12" + } to close the toolbox (close expected: ${shouldClose})` + ); + const onToolboxDestroy = gDevTools.once("toolbox-destroyed"); + + if (shortcut) { + EventUtils.synthesizeKey(shortcut.key, shortcut.options); + } else { + EventUtils.synthesizeKey("VK_F12", {}); + } + + if (shouldClose) { + await onToolboxDestroy; + } else { + const onTimeout = wait(1000).then(() => "TIMEOUT"); + const res = await Promise.race([onTimeout, onToolboxDestroy]); + is(res, "TIMEOUT", "No toolbox-destroyed event received"); + } + is( + !gDevTools.getToolboxForTab(tab), + shouldClose, + `Toolbox was ${shouldClose ? "" : "not "}closed for the test tab` + ); +}; + +const assertToolboxOpens = async function (tab, { shortcut, shouldOpen }) { + info( + `Use ${ + shortcut ? "shortcut" : "F12" + } to open the toolbox (open expected: ${shouldOpen})` + ); + const onToolboxReady = gDevTools.once("toolbox-ready"); + + if (shortcut) { + EventUtils.synthesizeKey(shortcut.key, shortcut.options); + } else { + EventUtils.synthesizeKey("VK_F12", {}); + } + + if (shouldOpen) { + await onToolboxReady; + info(`Received toolbox-ready`); + } else { + const onTimeout = wait(1000).then(() => "TIMEOUT"); + const res = await Promise.race([onTimeout, onToolboxReady]); + is(res, "TIMEOUT", "No toolbox-ready event received"); + } + is( + !!gDevTools.getToolboxForTab(tab), + shouldOpen, + `Toolbox was ${shouldOpen ? "" : "not "}opened for the test tab` + ); +}; diff --git a/devtools/client/framework/test/browser_toolbox_dynamic_registration.js b/devtools/client/framework/test/browser_toolbox_dynamic_registration.js new file mode 100644 index 0000000000..0ea7388eec --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_dynamic_registration.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = + "data:text/html,test for dynamically registering and unregistering tools"; + +var toolbox; + +function test() { + addTab(TEST_URL).then(async tab => { + gDevTools.showToolboxForTab(tab).then(testRegister); + }); +} + +function testRegister(aToolbox) { + toolbox = aToolbox; + gDevTools.once("tool-registered", toolRegistered); + + gDevTools.registerTool({ + id: "testTool", + label: "Test Tool", + inMenu: true, + isToolSupported: () => true, + build() {}, + }); +} + +function toolRegistered(toolId) { + is(toolId, "testTool", "tool-registered event handler sent tool id"); + + ok(gDevTools.getToolDefinitionMap().has(toolId), "tool added to map"); + + // test that it appeared in the UI + const doc = toolbox.doc; + const tab = getToolboxTab(doc, toolId); + ok(tab, "new tool's tab exists in toolbox UI"); + + const panel = doc.getElementById("toolbox-panel-" + toolId); + ok(panel, "new tool's panel exists in toolbox UI"); + + for (const win of getAllBrowserWindows()) { + const menuitem = win.document.getElementById("menuitem_" + toolId); + ok(menuitem, "menu item of new tool added to every browser window"); + } + + // then unregister it + testUnregister(); +} + +function getAllBrowserWindows() { + return Array.from(Services.wm.getEnumerator("navigator:browser")); +} + +function testUnregister() { + gDevTools.once("tool-unregistered", toolUnregistered); + + gDevTools.unregisterTool("testTool"); +} + +function toolUnregistered(toolId) { + is(toolId, "testTool", "tool-unregistered event handler sent tool id"); + + ok(!gDevTools.getToolDefinitionMap().has(toolId), "tool removed from map"); + + // test that it disappeared from the UI + const doc = toolbox.doc; + const tab = getToolboxTab(doc, toolId); + ok(!tab, "tool's tab was removed from the toolbox UI"); + + const panel = doc.getElementById("toolbox-panel-" + toolId); + ok(!panel, "tool's panel was removed from toolbox UI"); + + for (const win of getAllBrowserWindows()) { + const menuitem = win.document.getElementById("menuitem_" + toolId); + ok(!menuitem, "menu item removed from every browser window"); + } + + cleanup(); +} + +function cleanup() { + toolbox.destroy().then(() => { + toolbox = null; + gBrowser.removeCurrentTab(); + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_error_count.js b/devtools/client/framework/test/browser_toolbox_error_count.js new file mode 100644 index 0000000000..e4dcf0214f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_error_count.js @@ -0,0 +1,183 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +// Test for error icon and the error count displayed at right of the +// toolbox toolbar + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/webconsole/test/browser/shared-head.js", + this +); + +const TEST_URI = `https://example.com/document-builder.sjs?html=<meta charset=utf8></meta> +<script> + console.error("Cache Error1"); + console.exception(false, "Cache Exception"); + console.warn("Cache warning"); + console.assert(false, "Cache assert"); + cache.unknown.access +</script><body>`; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + // Make sure we start the test with the split console disabled. + await pushPref("devtools.toolbox.splitconsoleEnabled", false); + const tab = await addTab(TEST_URI); + + const toolbox = await openToolboxForTab( + tab, + "inspector", + Toolbox.HostType.BOTTOM + ); + + info("Check for cached errors"); + // (console.error + console.exception + console.assert + error) + let expectedErrorCount = 4; + + await waitFor(() => getErrorIcon(toolbox)); + is( + getErrorIcon(toolbox).getAttribute("title"), + "Show Split Console", + "Icon has expected title" + ); + is( + getErrorIconCount(toolbox), + expectedErrorCount, + "Correct count is displayed" + ); + + info("Check that calling console.clear clears the error count"); + ContentTask.spawn(tab.linkedBrowser, null, function () { + content.console.clear(); + }); + await waitFor( + () => !getErrorIcon(toolbox), + "Wait until the error button hides" + ); + ok(true, "The button was hidden after calling console.clear()"); + + info("Check that realtime errors increase the counter"); + ContentTask.spawn(tab.linkedBrowser, null, function () { + content.console.error("Live Error1"); + content.console.error("Live Error2"); + content.console.exception("Live Exception"); + content.console.warn("Live warning"); + content.console.assert(false, "Live assert"); + content.fetch("unknown-url-that-will-404"); + const script = content.document.createElement("script"); + script.textContent = `a.b.c.d`; + content.document.body.append(script); + }); + + expectedErrorCount = 6; + await waitFor(() => getErrorIconCount(toolbox) === expectedErrorCount); + + info("Check if split console opens on clicking the error icon"); + const onSplitConsoleOpen = toolbox.once("split-console"); + getErrorIcon(toolbox).click(); + await onSplitConsoleOpen; + ok( + toolbox.splitConsole, + "The split console was opened after clicking on the icon." + ); + + // Select the console and check that the icon title is updated + await toolbox.selectTool("webconsole"); + is( + getErrorIcon(toolbox).getAttribute("title"), + null, + "When the console is selected, the icon does not have a title" + ); + + const hud = toolbox.getCurrentPanel().hud; + const webconsoleDoc = hud.ui.window.document; + // wait until all error messages are displayed in the console + await waitFor( + async () => (await findAllErrors(hud)).length === expectedErrorCount + ); + + info("Clear the console output and check that the error icon is hidden"); + webconsoleDoc.querySelector(".devtools-clear-icon").click(); + await waitFor(() => !getErrorIcon(toolbox)); + ok(true, "Clearing the console does hide the icon"); + await waitFor(async () => (await findAllErrors(hud)).length === 0); + + info("Check that the error count is capped at 99"); + expectedErrorCount = 100; + ContentTask.spawn(tab.linkedBrowser, expectedErrorCount, function (count) { + for (let i = 0; i < count; i++) { + content.console.error(i); + } + }); + + // Wait until all the messages are displayed in the console + await waitFor( + async () => (await findAllErrors(hud)).length === expectedErrorCount + ); + + await waitFor(() => getErrorIconCount(toolbox) === "99+"); + ok(true, "The message count doesn't go higher than 99"); + + info( + "Reload the page and check that the error icon has the expected content" + ); + await reloadBrowser(); + + // (console.error, console.exception, console.assert and exception) + expectedErrorCount = 4; + await waitFor(() => getErrorIconCount(toolbox) === expectedErrorCount); + ok(true, "Correct count is displayed"); + + // wait until all error messages are displayed in the console + await waitFor( + async () => (await findAllErrors(hud)).length === expectedErrorCount + ); + + info("Disable the error icon from the options panel"); + const onOptionsSelected = toolbox.once("options-selected"); + toolbox.selectTool("options"); + const optionsPanel = await onOptionsSelected; + const errorCountButtonToggleEl = optionsPanel.panelWin.document.querySelector( + "input#command-button-errorcount" + ); + errorCountButtonToggleEl.click(); + + await waitFor(() => !getErrorIcon(toolbox)); + ok(true, "The error icon hides when disabling it from the settings panel"); + + info("Check that emitting new errors don't show the icon"); + ContentTask.spawn(tab.linkedBrowser, null, function () { + content.console.error("Live Error1 while disabled"); + content.console.error("Live Error2 while disabled"); + }); + + expectedErrorCount = expectedErrorCount + 2; + // Wait until messages are displayed in the console, so the toolbar would have the time + // to render the error icon again. + await toolbox.selectTool("webconsole"); + await waitFor( + async () => (await findAllErrors(hud)).length === expectedErrorCount + ); + is( + getErrorIcon(toolbox), + null, + "The icon is still hidden even after generating new errors" + ); + + info("Re-enable the error icon"); + await toolbox.selectTool("options"); + errorCountButtonToggleEl.click(); + await waitFor(() => getErrorIconCount(toolbox) === expectedErrorCount); + ok( + true, + "The error is displayed again, with the correct error count, after enabling it from the settings panel" + ); + + toolbox.destroy(); +}); + +function findAllErrors(hud) { + return findMessagesVirtualizedByType({ hud, typeSelector: ".error" }); +} diff --git a/devtools/client/framework/test/browser_toolbox_error_count_reset_on_navigation.js b/devtools/client/framework/test/browser_toolbox_error_count_reset_on_navigation.js new file mode 100644 index 0000000000..53f5068655 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_error_count_reset_on_navigation.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +// Test for error count in toolbar when navigating and webconsole isn't enabled +const TEST_URI = `http://example.org/document-builder.sjs?html=<meta charset=utf8></meta> +<script> + console.error("Cache Error1"); + console.exception(false, "Cache Exception"); + console.warn("Cache warning"); + console.assert(false, "Cache assert"); + cache.unknown.access +</script>`; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + // Disable bfcache for Fission for now. + // If Fission is disabled, the pref is no-op. + await SpecialPowers.pushPrefEnv({ + set: [["fission.bfcacheInParent", false]], + }); + + // Make sure we start the test with the split console disabled. + // ⚠️ In this test it's important to _not_ enable the console. + await pushPref("devtools.toolbox.splitconsoleEnabled", false); + const tab = await addTab(TEST_URI); + + const toolbox = await openToolboxForTab( + tab, + "inspector", + Toolbox.HostType.BOTTOM + ); + + info("Check for cached errors"); + // (console.error + console.exception + console.assert + error) + const expectedErrorCount = 4; + + await waitFor(() => getErrorIcon(toolbox)); + is( + getErrorIcon(toolbox).getAttribute("title"), + "Show Split Console", + "Icon has expected title" + ); + is( + getErrorIconCount(toolbox), + expectedErrorCount, + "Correct count is displayed" + ); + + info("Add another error so we have a different count"); + ContentTask.spawn(tab.linkedBrowser, null, function () { + content.console.error("Live Error1"); + }); + + const newExpectedErrorCount = expectedErrorCount + 1; + await waitFor(() => getErrorIconCount(toolbox) === newExpectedErrorCount); + + info( + "Reload the page and check that the error icon has the expected content" + ); + await reloadBrowser(); + + await waitFor( + () => getErrorIconCount(toolbox) === expectedErrorCount, + "Error count is cleared on navigation and then populated with the expected number of errors" + ); + ok(true, "Correct count is displayed"); + + info( + "Navigate to an error-less page and check that the error icon is hidden" + ); + await navigateTo(`data:text/html;charset=utf8,No errors`); + await waitFor( + () => !getErrorIcon(toolbox), + "Error count is cleared on navigation" + ); + ok( + true, + "The error icon was hidden when navigating to a new page without errors" + ); + + toolbox.destroy(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_fission_navigation.js b/devtools/client/framework/test/browser_toolbox_fission_navigation.js new file mode 100644 index 0000000000..123a06cce2 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_fission_navigation.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +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?html=<div id=org>org"; + +add_task(async function () { + const tab = await addTab(EXAMPLE_COM_URI); + + const toolbox = await openToolboxForTab(tab, "inspector"); + const comNode = await getNodeBySelector(toolbox, "#com"); + ok(comNode, "Found node for the COM page"); + + info("Navigate to the ORG page"); + await navigateTo(EXAMPLE_ORG_URI); + const orgNode = await getNodeBySelector(toolbox, "#org"); + ok(orgNode, "Found node for the ORG page"); + + info("Reload the ORG page"); + await navigateTo(EXAMPLE_ORG_URI); + const orgNodeAfterReload = await getNodeBySelector(toolbox, "#org"); + ok(orgNodeAfterReload, "Found node for the ORG page after reload"); + isnot(orgNode, orgNodeAfterReload, "The new node is different"); + + info("Navigate back to the COM page"); + await navigateTo(EXAMPLE_COM_URI); + const comNodeAfterNavigation = await getNodeBySelector(toolbox, "#com"); + ok(comNodeAfterNavigation, "Found node for the COM page after navigation"); + + info("Navigate to about:blank"); + await navigateTo("about:blank"); + const blankBodyAfterNavigation = await getNodeBySelector(toolbox, "body"); + ok( + blankBodyAfterNavigation, + "Found node for the about:blank page after navigation" + ); + + info("Navigate to about:robots"); + await navigateTo("about:robots"); + const aboutRobotsAfterNavigation = await getNodeBySelector( + toolbox, + "div.container" + ); + ok( + aboutRobotsAfterNavigation, + "Found node for the about:robots page after navigation" + ); +}); + +async function getNodeBySelector(toolbox, selector) { + const inspector = await toolbox.selectTool("inspector"); + return inspector.walker.querySelector(inspector.walker.rootNode, selector); +} diff --git a/devtools/client/framework/test/browser_toolbox_frames_list.js b/devtools/client/framework/test/browser_toolbox_frames_list.js new file mode 100644 index 0000000000..f1d7ff0510 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_frames_list.js @@ -0,0 +1,150 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that the frames list gets updated as iframes are added/removed from the document, +// and during navigation. + +const TEST_COM_URL = + "https://example.com/document-builder.sjs?html=<div id=com>com"; +const TEST_ORG_URL = + `https://example.org/document-builder.sjs?html=<div id=org>org</div>` + + `<iframe src="https://example.org/document-builder.sjs?html=example.org iframe"></iframe>` + + `<iframe src="https://example.com/document-builder.sjs?html=example.com iframe"></iframe>`; + +add_task(async function () { + // Enable the frames button. + await pushPref("devtools.command-button-frames.enabled", true); + + const tab = await addTab(TEST_COM_URL); + + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + }); + + ok( + !getFramesButton(toolbox), + "Frames button is not rendered when there's no iframes in the page" + ); + await checkFramesList(toolbox, []); + + info("Create a same origin (example.com) iframe"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const comIframe = content.document.createElement("iframe"); + comIframe.src = + "https://example.com/document-builder.sjs?html=example.com iframe"; + content.document.body.appendChild(comIframe); + }); + + await waitFor(() => getFramesButton(toolbox)); + ok(true, "Button is displayed when adding an iframe"); + + info("Check the content of the frames list"); + await checkFramesList(toolbox, [ + TEST_COM_URL, + "https://example.com/document-builder.sjs?html=example.com iframe", + ]); + + info("Create a cross-process origin (example.org) iframe"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const orgIframe = content.document.createElement("iframe"); + orgIframe.src = + "https://example.org/document-builder.sjs?html=example.org iframe"; + content.document.body.appendChild(orgIframe); + }); + + info("Check that the content of the frames list was updated"); + try { + await checkFramesList(toolbox, [ + TEST_COM_URL, + "https://example.com/document-builder.sjs?html=example.com iframe", + "https://example.org/document-builder.sjs?html=example.org iframe", + ]); + + // If Fission is enabled and EFT is not, we shouldn't hit this line as `checkFramesList` + // should throw (as remote frames are only displayed when EFT is enabled). + ok( + !isFissionEnabled() || isEveryFrameTargetEnabled(), + "iframe picker should only display remote frames when EFT is enabled" + ); + } catch (e) { + ok( + isFissionEnabled() && !isEveryFrameTargetEnabled(), + "iframe picker displays remote frames only when EFT is enabled" + ); + return; + } + + info("Reload and check that the frames list is cleared"); + await reloadBrowser(); + await waitFor(() => !getFramesButton(toolbox)); + ok( + true, + "The button was hidden when reloading as the page does not have iframes" + ); + await checkFramesList(toolbox, []); + + info("Navigate to a different origin, on a page with iframes"); + await navigateTo(TEST_ORG_URL); + await checkFramesList(toolbox, [ + TEST_ORG_URL, + "https://example.org/document-builder.sjs?html=example.org iframe", + "https://example.com/document-builder.sjs?html=example.com iframe", + ]); + + info("Check that frames list is updated when removing same-origin iframe"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + content.document.querySelector("iframe").remove(); + }); + await checkFramesList(toolbox, [ + TEST_ORG_URL, + "https://example.com/document-builder.sjs?html=example.com iframe", + ]); + + info("Check that frames list is updated when removing cross-origin iframe"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + content.document.querySelector("iframe").remove(); + }); + await waitFor(() => !getFramesButton(toolbox)); + ok(true, "The button was hidden when removing the last iframe on the page"); + await checkFramesList(toolbox, []); + + info("Check that the list does have expected items after reloading"); + await reloadBrowser(); + await waitFor(() => getFramesButton(toolbox)); + ok(true, "button is displayed after reloading"); + await checkFramesList(toolbox, [ + TEST_ORG_URL, + "https://example.org/document-builder.sjs?html=example.org iframe", + "https://example.com/document-builder.sjs?html=example.com iframe", + ]); +}); + +function getFramesButton(toolbox) { + return toolbox.doc.getElementById("command-button-frames"); +} + +async function checkFramesList(toolbox, expectedFrames) { + const frames = await waitFor(() => { + // items might be added in the list before their url is known, so exclude empty items. + const f = getFramesLabels(toolbox).filter(t => t !== ""); + if (f.length !== expectedFrames.length) { + return false; + } + + return f; + }); + + is( + JSON.stringify(frames.sort()), + JSON.stringify(expectedFrames.sort()), + "The expected frames are displayed" + ); +} + +function getFramesLabels(toolbox) { + return Array.from( + toolbox.doc.querySelectorAll("#toolbox-frame-menu .command .label") + ).map(el => el.textContent); +} diff --git a/devtools/client/framework/test/browser_toolbox_getpanelwhenready.js b/devtools/client/framework/test/browser_toolbox_getpanelwhenready.js new file mode 100644 index 0000000000..85436c2925 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_getpanelwhenready.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that getPanelWhenReady returns the correct panel in promise +// resolutions regardless of whether it has opened first. + +var toolbox = null; + +const URL = "data:text/html;charset=utf8,test for getPanelWhenReady"; + +add_task(async function () { + const tab = await addTab(URL); + toolbox = await gDevTools.showToolboxForTab(tab); + + const debuggerPanelPromise = toolbox.getPanelWhenReady("jsdebugger"); + await toolbox.selectTool("jsdebugger"); + const debuggerPanel = await debuggerPanelPromise; + + is( + debuggerPanel, + toolbox.getPanel("jsdebugger"), + "The debugger panel from getPanelWhenReady before loading is the actual panel" + ); + + const debuggerPanel2 = await toolbox.getPanelWhenReady("jsdebugger"); + is( + debuggerPanel2, + toolbox.getPanel("jsdebugger"), + "The debugger panel from getPanelWhenReady after loading is the actual panel" + ); + + await cleanup(); +}); + +async function cleanup() { + await toolbox.destroy(); + gBrowser.removeCurrentTab(); + toolbox = null; +} diff --git a/devtools/client/framework/test/browser_toolbox_highlight.js b/devtools/client/framework/test/browser_toolbox_highlight.js new file mode 100644 index 0000000000..d0712aeed5 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_highlight.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +var toolbox = null; + +function test() { + (async function () { + const URL = "data:text/plain;charset=UTF-8,Nothing to see here, move along"; + + const TOOL_ID_1 = "jsdebugger"; + const TOOL_ID_2 = "webconsole"; + await addTab(URL); + + toolbox = await gDevTools.showToolboxForTab(gBrowser.selectedTab, { + toolId: TOOL_ID_1, + hostType: Toolbox.HostType.BOTTOM, + }); + + // select tool 2 + await toolbox.selectTool(TOOL_ID_2); + // and highlight the first one + await highlightTab(TOOL_ID_1); + // to see if it has the proper class. + await checkHighlighted(TOOL_ID_1); + // Now switch back to first tool + await toolbox.selectTool(TOOL_ID_1); + // to check again. But there is no easy way to test if + // it is showing orange or not. + await checkNoHighlightWhenSelected(TOOL_ID_1); + // Switch to tool 2 again + await toolbox.selectTool(TOOL_ID_2); + // and check again. + await checkHighlighted(TOOL_ID_1); + // Highlight another tool + await highlightTab(TOOL_ID_2); + // Check that both tools are highlighted. + await checkHighlighted(TOOL_ID_1); + // Check second tool being both highlighted and selected. + await checkNoHighlightWhenSelected(TOOL_ID_2); + // Select tool 1 + await toolbox.selectTool(TOOL_ID_1); + // Check second tool is still highlighted + await checkHighlighted(TOOL_ID_2); + // Unhighlight the second tool + await unhighlightTab(TOOL_ID_2); + // to see the classes gone. + await checkNoHighlight(TOOL_ID_2); + // Now unhighlight the tool + await unhighlightTab(TOOL_ID_1); + // to see the classes gone. + await checkNoHighlight(TOOL_ID_1); + + // Now close the toolbox and exit. + executeSoon(() => { + toolbox.destroy().then(() => { + toolbox = null; + gBrowser.removeCurrentTab(); + finish(); + }); + }); + })().catch(error => { + ok(false, "There was an error running the test."); + }); +} + +function highlightTab(toolId) { + info(`Highlighting tool ${toolId}'s tab.`); + return toolbox.highlightTool(toolId); +} + +function unhighlightTab(toolId) { + info(`Unhighlighting tool ${toolId}'s tab.`); + return toolbox.unhighlightTool(toolId); +} + +function checkHighlighted(toolId) { + const tab = toolbox.doc.getElementById("toolbox-tab-" + toolId); + ok( + toolbox.isHighlighted(toolId), + `Toolbox.isHighlighted reports ${toolId} as highlighted` + ); + ok( + tab.classList.contains("highlighted"), + `The highlighted class is present in ${toolId}.` + ); + ok( + !tab.classList.contains("selected"), + `The tab is not selected in ${toolId}` + ); +} + +function checkNoHighlightWhenSelected(toolId) { + const tab = toolbox.doc.getElementById("toolbox-tab-" + toolId); + ok( + toolbox.isHighlighted(toolId), + `Toolbox.isHighlighted reports ${toolId} as highlighted` + ); + ok( + tab.classList.contains("highlighted"), + `The highlighted class is present in ${toolId}` + ); + ok( + tab.classList.contains("selected"), + `And the tab is selected, so the orange glow will not be present. in ${toolId}` + ); +} + +function checkNoHighlight(toolId) { + const tab = toolbox.doc.getElementById("toolbox-tab-" + toolId); + ok( + !toolbox.isHighlighted(toolId), + `Toolbox.isHighlighted reports ${toolId} as not highlighted` + ); + ok( + !tab.classList.contains("highlighted"), + `The highlighted class is not present in ${toolId}` + ); +} diff --git a/devtools/client/framework/test/browser_toolbox_hosts.js b/devtools/client/framework/test/browser_toolbox_hosts.js new file mode 100644 index 0000000000..37738865a9 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_hosts.js @@ -0,0 +1,226 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + gDevToolsBrowser, +} = require("resource://devtools/client/framework/devtools-browser.js"); + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); +const { LEFT, RIGHT, BOTTOM, WINDOW } = Toolbox.HostType; +let toolbox; + +// We are opening/close toolboxes many times, +// which introduces long GC pauses between each sub task +// and requires some more time to run in DEBUG builds. +requestLongerTimeout(2); + +const URL = + "data:text/html;charset=utf8,test for opening toolbox in different hosts"; + +add_task(async function () { + const win = await BrowserTestUtils.openNewBrowserWindow(); + win.gBrowser.selectedTab = BrowserTestUtils.addTab(win.gBrowser, URL); + + const tab = win.gBrowser.selectedTab; + toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + hostType: Toolbox.HostType.WINDOW, + }); + const onToolboxClosed = toolbox.once("destroyed"); + ok( + gDevToolsBrowser.hasToolboxOpened(win), + "hasToolboxOpened is true before closing the toolbox" + ); + await BrowserTestUtils.closeWindow(win); + ok( + !gDevToolsBrowser.hasToolboxOpened(win), + "hasToolboxOpened is false after closing the window" + ); + + info("Wait for toolbox to be destroyed after browser window is closed"); + await onToolboxClosed; + toolbox = null; +}); + +add_task(async function runTest() { + info("Create a test tab and open the toolbox"); + const tab = await addTab(URL); + toolbox = await gDevTools.showToolboxForTab(tab, { toolId: "webconsole" }); + + await runHostTests(gBrowser); + await toolbox.destroy(); + + toolbox = null; + gBrowser.removeCurrentTab(); +}); + +// We run the same host switching tests in a private window. +// See Bug 1581093 for an example of issue specific to private windows. +add_task(async function runPrivateWindowTest() { + info("Create a private window + tab and open the toolbox"); + await runHostTestsFromSeparateWindow({ + private: true, + }); +}); + +// We run the same host switching tests in a non-fission window. +// See Bug 1650963 for an example of issue specific to private windows. +add_task(async function runNonFissionWindowTest() { + info("Create a non-fission window + tab and open the toolbox"); + await runHostTestsFromSeparateWindow({ + fission: false, + }); +}); + +async function runHostTestsFromSeparateWindow(options) { + const win = await BrowserTestUtils.openNewBrowserWindow(options); + const browser = win.gBrowser; + browser.selectedTab = BrowserTestUtils.addTab(browser, URL); + + const tab = browser.selectedTab; + toolbox = await gDevTools.showToolboxForTab(tab, { toolId: "webconsole" }); + + await runHostTests(browser); + await toolbox.destroy(); + + toolbox = null; + await BrowserTestUtils.closeWindow(win); +} + +async function runHostTests(browser) { + await testBottomHost(browser); + await testLeftHost(browser); + await testRightHost(browser); + await testWindowHost(browser); + await testToolSelect(); + await testDestroy(browser); + await testRememberHost(); + await testPreviousHost(); +} + +function testBottomHost(browser) { + checkHostType(toolbox, BOTTOM); + + // test UI presence + const panel = browser.getPanel(); + const iframe = panel.querySelector(".devtools-toolbox-bottom-iframe"); + ok(iframe, "toolbox bottom iframe exists"); + + checkToolboxLoaded(iframe); +} + +async function testLeftHost(browser) { + await toolbox.switchHost(LEFT); + checkHostType(toolbox, LEFT); + + // test UI presence + const panel = browser.getPanel(); + const bottom = panel.querySelector(".devtools-toolbox-bottom-iframe"); + ok(!bottom, "toolbox bottom iframe doesn't exist"); + + const iframe = panel.querySelector(".devtools-toolbox-side-iframe"); + ok(iframe, "toolbox side iframe exists"); + + checkToolboxLoaded(iframe); +} + +async function testRightHost(browser) { + await toolbox.switchHost(RIGHT); + checkHostType(toolbox, RIGHT); + + // test UI presence + const panel = browser.getPanel(); + const bottom = panel.querySelector(".devtools-toolbox-bottom-iframe"); + ok(!bottom, "toolbox bottom iframe doesn't exist"); + + const iframe = panel.querySelector(".devtools-toolbox-side-iframe"); + ok(iframe, "toolbox side iframe exists"); + + checkToolboxLoaded(iframe); +} + +async function testWindowHost(browser) { + await toolbox.switchHost(WINDOW); + checkHostType(toolbox, WINDOW); + + const panel = browser.getPanel(); + const sidebar = panel.querySelector(".devtools-toolbox-side-iframe"); + ok(!sidebar, "toolbox sidebar iframe doesn't exist"); + + const win = Services.wm.getMostRecentWindow("devtools:toolbox"); + ok(win, "toolbox separate window exists"); + + const iframe = win.document.querySelector(".devtools-toolbox-window-iframe"); + checkToolboxLoaded(iframe); +} + +async function testToolSelect() { + // make sure we can load a tool after switching hosts + await toolbox.selectTool("inspector"); +} + +async function testDestroy(browser) { + await toolbox.destroy(); + toolbox = await gDevTools.showToolboxForTab(browser.selectedTab); +} + +function testRememberHost() { + // last host was the window - make sure it's the same when re-opening + is(toolbox.hostType, WINDOW, "host remembered"); + + const win = Services.wm.getMostRecentWindow("devtools:toolbox"); + ok(win, "toolbox separate window exists"); +} + +async function testPreviousHost() { + // last host was the window - make sure it's the same when re-opening + is(toolbox.hostType, WINDOW, "host remembered"); + + info("Switching to left"); + await toolbox.switchHost(LEFT); + checkHostType(toolbox, LEFT, WINDOW); + + info("Switching to right"); + await toolbox.switchHost(RIGHT); + checkHostType(toolbox, RIGHT, LEFT); + + info("Switching to bottom"); + await toolbox.switchHost(BOTTOM); + checkHostType(toolbox, BOTTOM, RIGHT); + + info("Switching from bottom to right"); + await toolbox.switchToPreviousHost(); + checkHostType(toolbox, RIGHT, BOTTOM); + + info("Switching from right to bottom"); + await toolbox.switchToPreviousHost(); + checkHostType(toolbox, BOTTOM, RIGHT); + + info("Switching to window"); + await toolbox.switchHost(WINDOW); + checkHostType(toolbox, WINDOW, BOTTOM); + + info("Switching from window to bottom"); + await toolbox.switchToPreviousHost(); + checkHostType(toolbox, BOTTOM, WINDOW); + + info("Forcing the previous host to match the current (bottom)"); + Services.prefs.setCharPref("devtools.toolbox.previousHost", BOTTOM); + + info("Switching from bottom to right (since previous=current=bottom"); + await toolbox.switchToPreviousHost(); + checkHostType(toolbox, RIGHT, BOTTOM); + + info("Forcing the previous host to match the current (right)"); + Services.prefs.setCharPref("devtools.toolbox.previousHost", RIGHT); + info("Switching from right to bottom (since previous=current=side"); + await toolbox.switchToPreviousHost(); + checkHostType(toolbox, BOTTOM, RIGHT); +} + +function checkToolboxLoaded(iframe) { + const tabs = iframe.contentDocument.querySelector(".toolbox-tabs"); + ok(tabs, "toolbox UI has been loaded into iframe"); +} diff --git a/devtools/client/framework/test/browser_toolbox_hosts_size.js b/devtools/client/framework/test/browser_toolbox_hosts_size.js new file mode 100644 index 0000000000..81cce09a67 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_hosts_size.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that getPanelWhenReady returns the correct panel in promise +// resolutions regardless of whether it has opened first. + +const URL = "data:text/html;charset=utf8,test for host sizes"; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + // Set size prefs to make the hosts way too big, so that the size has + // to be clamped to fit into the browser window. + Services.prefs.setIntPref("devtools.toolbox.footer.height", 10000); + Services.prefs.setIntPref("devtools.toolbox.sidebar.width", 10000); + + const tab = await addTab(URL); + const panel = gBrowser.getPanel(); + const { clientHeight: panelHeight, clientWidth: panelWidth } = panel; + const toolbox = await gDevTools.showToolboxForTab(tab); + + is( + panel.clientHeight, + panelHeight, + "Opening the toolbox hasn't changed the height of the panel" + ); + is( + panel.clientWidth, + panelWidth, + "Opening the toolbox hasn't changed the width of the panel" + ); + + let iframe = panel.querySelector(".devtools-toolbox-bottom-iframe"); + is( + iframe.clientHeight, + panelHeight - 25, + "The iframe fits within the available space" + ); + + iframe.style.height = "10000px"; // Set height to something unreasonably large. + Assert.less( + iframe.clientHeight, + panelHeight, + `The iframe fits within the available space (${iframe.clientHeight} < ${panelHeight})` + ); + + await toolbox.switchHost(Toolbox.HostType.RIGHT); + iframe = panel.querySelector(".devtools-toolbox-side-iframe"); + iframe.style.minWidth = "1px"; // Disable the min width set in css + is( + iframe.clientWidth, + panelWidth - 25, + "The iframe fits within the available space" + ); + + const oldWidth = iframe.style.width; + iframe.style.width = "10000px"; // Set width to something unreasonably large. + Assert.less( + iframe.clientWidth, + panelWidth, + `The iframe fits within the available space (${iframe.clientWidth} < ${panelWidth})` + ); + iframe.style.width = oldWidth; + + // on shutdown, the sidebar width will be set to the clientWidth of the iframe + const expectedWidth = iframe.clientWidth; + + info("waiting for cleanup"); + await cleanup(toolbox); + // Wait until the toolbox-host-manager was destroyed and updated the preferences + // to avoid side effects in the next test. + await waitUntil(() => { + const savedWidth = Services.prefs.getIntPref( + "devtools.toolbox.sidebar.width" + ); + info(`waiting for saved pref: ${savedWidth}, ${expectedWidth}`); + return savedWidth === expectedWidth; + }); +}); + +add_task(async function () { + // Set size prefs to something reasonable, so we can check to make sure + // they are being set properly. + Services.prefs.setIntPref("devtools.toolbox.footer.height", 100); + Services.prefs.setIntPref("devtools.toolbox.sidebar.width", 100); + + const tab = await addTab(URL); + const panel = gBrowser.getPanel(); + const { clientHeight: panelHeight, clientWidth: panelWidth } = panel; + const toolbox = await gDevTools.showToolboxForTab(tab); + + is( + panel.clientHeight, + panelHeight, + "Opening the toolbox hasn't changed the height of the panel" + ); + is( + panel.clientWidth, + panelWidth, + "Opening the toolbox hasn't changed the width of the panel" + ); + + let iframe = panel.querySelector(".devtools-toolbox-bottom-iframe"); + is(iframe.clientHeight, 100, "The iframe is resized properly"); + const horzSplitter = panel.querySelector(".devtools-horizontal-splitter"); + dragElement(horzSplitter, { startX: 1, startY: 1, deltaX: 0, deltaY: -50 }); + is(iframe.clientHeight, 150, "The iframe was resized by the splitter"); + + await toolbox.switchHost(Toolbox.HostType.RIGHT); + iframe = panel.querySelector(".devtools-toolbox-side-iframe"); + iframe.style.minWidth = "1px"; // Disable the min width set in css + is(iframe.clientWidth, 100, "The iframe is resized properly"); + + info("Resize the toolbox manually by 50 pixels"); + const sideSplitter = panel.querySelector(".devtools-side-splitter"); + dragElement(sideSplitter, { startX: 1, startY: 1, deltaX: -50, deltaY: 0 }); + is(iframe.clientWidth, 150, "The iframe was resized by the splitter"); + + await cleanup(toolbox); +}); + +function dragElement(el, { startX, startY, deltaX, deltaY }) { + const endX = startX + deltaX; + const endY = startY + deltaY; + EventUtils.synthesizeMouse(el, startX, startY, { type: "mousedown" }, window); + EventUtils.synthesizeMouse(el, endX, endY, { type: "mousemove" }, window); + EventUtils.synthesizeMouse(el, endX, endY, { type: "mouseup" }, window); +} + +async function cleanup(toolbox) { + Services.prefs.clearUserPref("devtools.toolbox.host"); + Services.prefs.clearUserPref("devtools.toolbox.footer.height"); + Services.prefs.clearUserPref("devtools.toolbox.sidebar.width"); + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +} diff --git a/devtools/client/framework/test/browser_toolbox_hosts_telemetry.js b/devtools/client/framework/test/browser_toolbox_hosts_telemetry.js new file mode 100644 index 0000000000..92992048dd --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_hosts_telemetry.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); +const { LEFT, RIGHT, BOTTOM, WINDOW } = Toolbox.HostType; + +const URL = "data:text/html;charset=utf8,browser_toolbox_hosts_telemetry.js"; + +add_task(async function () { + startTelemetry(); + + info("Create a test tab and open the toolbox"); + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + }); + + await changeToolboxHost(toolbox); + await checkResults(); +}); + +async function changeToolboxHost(toolbox) { + info("Switch toolbox host"); + await toolbox.switchHost(RIGHT); + await toolbox.switchHost(WINDOW); + await toolbox.switchHost(BOTTOM); + await toolbox.switchHost(LEFT); + await toolbox.switchHost(RIGHT); + await toolbox.switchHost(WINDOW); + await toolbox.switchHost(BOTTOM); + await toolbox.switchHost(LEFT); + await toolbox.switchHost(RIGHT); +} + +function checkResults() { + // Check for: + // - 3 "bottom" entries. + // - 2 "left" entries. + // - 3 "right" entries. + // - 2 "window" entries. + checkTelemetry( + "DEVTOOLS_TOOLBOX_HOST", + "", + { 0: 3, 1: 3, 2: 2, 4: 2, 5: 0 }, + "array" + ); +} diff --git a/devtools/client/framework/test/browser_toolbox_keyboard_navigation.js b/devtools/client/framework/test/browser_toolbox_keyboard_navigation.js new file mode 100644 index 0000000000..17ba9efcf9 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_keyboard_navigation.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests keyboard navigation of devtools tabbar. + +const TEST_URL = + "data:text/html;charset=utf8,test page for toolbar keyboard navigation"; + +function containsFocus(aDoc, aElm) { + let elm = aDoc.activeElement; + while (elm) { + if (elm === aElm) { + return true; + } + elm = elm.parentNode; + } + return false; +} + +add_task(async function () { + info("Create a test tab and open the toolbox"); + const toolbox = await openNewTabAndToolbox(TEST_URL, "webconsole"); + const doc = toolbox.doc; + + const toolbar = doc.querySelector(".devtools-tabbar"); + const toolbarControls = [ + ...toolbar.querySelectorAll(".devtools-tab, button"), + ].filter( + elm => + !elm.hidden && + doc.defaultView.getComputedStyle(elm).getPropertyValue("display") !== + "none" + ); + + // Put the keyboard focus onto the first toolbar control. + toolbarControls[0].focus(); + ok(containsFocus(doc, toolbar), "Focus is within the toolbar"); + + // Move the focus away from toolbar to a next focusable element. + EventUtils.synthesizeKey("KEY_Tab"); + ok(!containsFocus(doc, toolbar), "Focus is outside of the toolbar"); + + // Move the focus back to the toolbar. + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + ok(containsFocus(doc, toolbar), "Focus is within the toolbar again"); + + // Move through the toolbar forward using the right arrow key. + for (let i = 0; i < toolbarControls.length; ++i) { + is(doc.activeElement.id, toolbarControls[i].id, "New control is focused"); + if (i < toolbarControls.length - 1) { + EventUtils.synthesizeKey("KEY_ArrowRight"); + } + } + + // Move the focus away from toolbar to a next focusable element. + EventUtils.synthesizeKey("KEY_Tab"); + ok(!containsFocus(doc, toolbar), "Focus is outside of the toolbar"); + + // Move the focus back to the toolbar. + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + ok(containsFocus(doc, toolbar), "Focus is within the toolbar again"); + + // Move through the toolbar backward using the left arrow key. + for (let i = toolbarControls.length - 1; i >= 0; --i) { + is(doc.activeElement.id, toolbarControls[i].id, "New control is focused"); + if (i > 0) { + EventUtils.synthesizeKey("KEY_ArrowLeft"); + } + } + + // Move focus to the 3rd (non-first) toolbar control. + const expectedFocusedControl = toolbarControls[2]; + EventUtils.synthesizeKey("KEY_ArrowRight"); + EventUtils.synthesizeKey("KEY_ArrowRight"); + is(doc.activeElement.id, expectedFocusedControl.id, "New control is focused"); + + // Move the focus away from toolbar to a next focusable element. + EventUtils.synthesizeKey("KEY_Tab"); + ok(!containsFocus(doc, toolbar), "Focus is outside of the toolbar"); + + // Move the focus back to the toolbar, ensure we land on the last active + // descendant control. + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + is(doc.activeElement.id, expectedFocusedControl.id, "New control is focused"); +}); + +// Test that moving the focus of tab button and selecting it. +add_task(async function () { + info("Create a test tab and open the toolbox"); + const toolbox = await openNewTabAndToolbox(TEST_URL, "inspector"); + const doc = toolbox.doc; + + const toolbar = doc.querySelector(".toolbox-tabs"); + const tabButtons = toolbar.querySelectorAll(".devtools-tab, button"); + const win = tabButtons[0].ownerDocument.defaultView; + + // Put the keyboard focus onto the first tab button. + tabButtons[0].focus(); + ok(containsFocus(doc, toolbar), "Focus is within the toolbox"); + is(doc.activeElement.id, tabButtons[0].id, "First tab button is focused."); + + // Move the focused tab and select it by using enter key. + let onKeyEvent = once(win, "keydown"); + EventUtils.synthesizeKey("KEY_ArrowRight"); + await onKeyEvent; + + let onceSelected = toolbox.once("webconsole-selected"); + EventUtils.synthesizeKey("Enter"); + await onceSelected; + is( + doc.activeElement.id, + "toolbox-panel-iframe-" + toolbox.currentToolId, + "Selected tool frame is now focused." + ); + + // Webconsole steal the focus from button after sending "webconsole-selected" + // event. + tabButtons[1].focus(); + + // Return the focused tab with space key. + onKeyEvent = once(win, "keydown"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + await onKeyEvent; + + onceSelected = toolbox.once("inspector-selected"); + EventUtils.synthesizeKey(" "); + await onceSelected; + + is( + doc.activeElement.id, + "toolbox-panel-iframe-" + toolbox.currentToolId, + "Selected tool frame is now focused." + ); +}); diff --git a/devtools/client/framework/test/browser_toolbox_keyboard_navigation_notification_box.js b/devtools/client/framework/test/browser_toolbox_keyboard_navigation_notification_box.js new file mode 100644 index 0000000000..135559cb2f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_keyboard_navigation_notification_box.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests keyboard navigation of the DevTools notification box. + +// The test page attempts to load a stylesheet at an invalid URL which will +// trigger a devtools notification to show up on top of the window. +const TEST_PAGE = `<link rel="stylesheet" type="text/css" href="http://mochi.test:1234/invalid.port">`; +const TEST_URL = `data:text/html;charset=utf8,${TEST_PAGE}`; + +add_task(async function () { + info("Create a test tab and open the toolbox"); + const toolbox = await openNewTabAndToolbox(TEST_URL, "styleeditor"); + const doc = toolbox.doc; + + info("Wait until the notification box displays the stylesheet warning"); + const notificationBox = await waitFor(() => + doc.querySelector(".notificationbox") + ); + + ok( + notificationBox.querySelector(".notification"), + "A notification is rendered" + ); + + const toolbar = doc.querySelector(".devtools-tabbar"); + const tabButtons = toolbar.querySelectorAll(".devtools-tab, button"); + + // Put the keyboard focus onto the first tab button. + tabButtons[0].focus(); + is(doc.activeElement.id, tabButtons[0].id, "First tab button is focused."); + + // Move the focus to the notification box. + info("Send a shift+tab key event to focus the previous focusable element"); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + is( + doc.activeElement, + notificationBox.querySelector(".messageCloseButton"), + "The focus is on the close button of the notification" + ); + + info("Send a vk_space key event to click on the close button"); + EventUtils.synthesizeKey("VK_SPACE"); + + info("Wait until the notification is removed"); + await waitUntil(() => !notificationBox.querySelector(".notificationbox")); +}); diff --git a/devtools/client/framework/test/browser_toolbox_meatball.js b/devtools/client/framework/test/browser_toolbox_meatball.js new file mode 100644 index 0000000000..04d9a3a0cd --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_meatball.js @@ -0,0 +1,135 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Sanity test for meatball menu. +// +// We also use this to test the common Menu* components since we don't currently +// have a means of testing React components in isolation. + +const { + focusableSelector, +} = require("resource://devtools/client/shared/focus.js"); +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + const tab = await addTab("about:blank"); + const toolbox = await openToolboxForTab( + tab, + "inspector", + Toolbox.HostType.BOTTOM + ); + + info("Check opening meatball menu by clicking the menu button"); + await openMeatballMenuWithClick(toolbox); + const menuDockToBottom = toolbox.doc.getElementById( + "toolbox-meatball-menu-dock-bottom" + ); + Assert.strictEqual( + menuDockToBottom.getAttribute("aria-checked"), + "true", + "menuDockToBottom has checked" + ); + + info("Check closing meatball menu by clicking outside the popup area"); + await closeMeatballMenuWithClick(toolbox); + + info("Check moving the focus element with key event"); + await openMeatballMenuWithClick(toolbox); + checkKeyHandling(toolbox); + + info("Check closing meatball menu with escape key"); + EventUtils.synthesizeKey("VK_ESCAPE", {}, toolbox.win); + await waitForMeatballMenuToClose(toolbox); + + // F1 should trigger the settings panel and close the menu at the same time. + info("Check closing meatball menu with F1 key"); + await openMeatballMenuWithClick(toolbox); + EventUtils.synthesizeKey("VK_F1", {}, toolbox.win); + await waitForMeatballMenuToClose(toolbox); + + await toolbox.destroy(); +}); + +async function openMeatballMenuWithClick(toolbox) { + const meatballButton = toolbox.doc.getElementById( + "toolbox-meatball-menu-button" + ); + await waitUntil(() => meatballButton.style.pointerEvents !== "none"); + EventUtils.synthesizeMouseAtCenter(meatballButton, {}, toolbox.win); + + const panel = toolbox.doc.querySelectorAll(".tooltip-xul-wrapper"); + const shownListener = new Promise(res => { + panel[0].addEventListener("popupshown", res, { once: true }); + }); + + const menuPanel = toolbox.doc.getElementById( + "toolbox-meatball-menu-button-panel" + ); + ok(menuPanel, "meatball panel is available"); + + info("Waiting for the menu panel to be displayed"); + + await shownListener; + await waitUntil(() => menuPanel.classList.contains("tooltip-visible")); +} + +async function closeMeatballMenuWithClick(toolbox) { + const meatballButton = toolbox.doc.getElementById( + "toolbox-meatball-menu-button" + ); + await waitUntil( + () => toolbox.win.getComputedStyle(meatballButton).pointerEvents === "none" + ); + meatballButton.click(); + + const menuPanel = toolbox.doc.getElementById( + "toolbox-meatball-menu-button-panel" + ); + ok(menuPanel, "meatball panel is available"); + + info("Waiting for the menu panel to be hidden"); + await waitUntil(() => !menuPanel.classList.contains("tooltip-visible")); +} + +async function waitForMeatballMenuToClose(toolbox) { + const menuPanel = toolbox.doc.getElementById( + "toolbox-meatball-menu-button-panel" + ); + ok(menuPanel, "meatball panel is available"); + + info("Waiting for the menu panel to be hidden"); + await waitUntil(() => !menuPanel.classList.contains("tooltip-visible")); +} + +function checkKeyHandling(toolbox) { + const selectable = toolbox.doc + .getElementById("toolbox-meatball-menu") + .querySelectorAll(focusableSelector); + + EventUtils.synthesizeKey("VK_DOWN", {}, toolbox.win); + is( + toolbox.doc.activeElement, + selectable[0], + "First item selected with down key." + ); + EventUtils.synthesizeKey("VK_UP", {}, toolbox.win); + is( + toolbox.doc.activeElement, + selectable[selectable.length - 1], + "End item selected with up key." + ); + EventUtils.synthesizeKey("VK_HOME", {}, toolbox.win); + is( + toolbox.doc.activeElement, + selectable[0], + "First item selected with home key." + ); + EventUtils.synthesizeKey("VK_END", {}, toolbox.win); + is( + toolbox.doc.activeElement, + selectable[selectable.length - 1], + "End item selected with down key." + ); +} diff --git a/devtools/client/framework/test/browser_toolbox_options.js b/devtools/client/framework/test/browser_toolbox_options.js new file mode 100644 index 0000000000..3b5d7f2661 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options.js @@ -0,0 +1,556 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that changing preferences in the options panel updates the prefs +// and toggles appropriate things in the toolbox. + +var doc = null, + toolbox = null, + panelWin = null, + modifiedPrefs = []; +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); +const { PrefObserver } = require("resource://devtools/client/shared/prefs.js"); + +add_task(async function () { + const URL = + "data:text/html;charset=utf8,test for dynamically registering " + + "and unregistering tools"; + registerNewTool(); + const tab = await addTab(URL); + toolbox = await gDevTools.showToolboxForTab(tab); + + doc = toolbox.doc; + await registerNewPerToolboxTool(); + await testSelectTool(); + await testOptionsShortcut(); + await testOptions(); + await testToggleTools(); + + // Test that registered WebExtensions becomes entries in the + // options panel and toggling their checkbox toggle the related + // preference. + await registerNewWebExtensions(); + await testToggleWebExtensions(); + + await cleanup(); +}); + +function registerNewTool() { + const toolDefinition = { + id: "testTool", + isToolSupported: () => true, + visibilityswitch: "devtools.test-tool.enabled", + url: "about:blank", + label: "someLabel", + }; + + ok(gDevTools, "gDevTools exists"); + ok( + !gDevTools.getToolDefinitionMap().has("testTool"), + "The tool is not registered" + ); + + gDevTools.registerTool(toolDefinition); + ok( + gDevTools.getToolDefinitionMap().has("testTool"), + "The tool is registered" + ); +} + +// Register a fake WebExtension to check that it is +// listed in the toolbox options. +function registerNewWebExtensions() { + // Register some fake extensions and init the related preferences + // (similarly to ext-devtools.js). + for (let i = 0; i < 2; i++) { + const extPref = `devtools.webextensions.fakeExtId${i}.enabled`; + Services.prefs.setBoolPref(extPref, true); + + toolbox.registerWebExtension(`fakeUUID${i}`, { + name: `Fake WebExtension ${i}`, + pref: extPref, + }); + } +} + +function registerNewPerToolboxTool() { + const toolDefinition = { + id: "test-pertoolbox-tool", + isToolSupported: () => true, + visibilityswitch: "devtools.test-pertoolbox-tool.enabled", + url: "about:blank", + label: "perToolboxSomeLabel", + }; + + ok(gDevTools, "gDevTools exists"); + ok( + !gDevTools.getToolDefinitionMap().has("test-pertoolbox-tool"), + "The per-toolbox tool is not registered globally" + ); + + ok(toolbox, "toolbox exists"); + ok( + !toolbox.hasAdditionalTool("test-pertoolbox-tool"), + "The per-toolbox tool is not yet registered to the toolbox" + ); + + toolbox.addAdditionalTool(toolDefinition); + + ok( + !gDevTools.getToolDefinitionMap().has("test-pertoolbox-tool"), + "The per-toolbox tool is not registered globally" + ); + ok( + toolbox.hasAdditionalTool("test-pertoolbox-tool"), + "The per-toolbox tool has been registered to the toolbox" + ); +} + +async function testSelectTool() { + info("Checking to make sure that the options panel can be selected."); + + const onceSelected = toolbox.once("options-selected"); + toolbox.selectTool("options"); + await onceSelected; + ok(true, "Toolbox selected via selectTool method"); +} + +async function testOptionsShortcut() { + info("Selecting another tool, then reselecting options panel with keyboard."); + + await toolbox.selectTool("webconsole"); + is(toolbox.currentToolId, "webconsole", "webconsole is selected"); + synthesizeKeyShortcut(L10N.getStr("toolbox.help.key")); + is(toolbox.currentToolId, "options", "Toolbox selected via shortcut key"); + synthesizeKeyShortcut(L10N.getStr("toolbox.help.key")); + is(toolbox.currentToolId, "webconsole", "webconsole is reselected"); + synthesizeKeyShortcut(L10N.getStr("toolbox.help.key")); + is(toolbox.currentToolId, "options", "Toolbox selected via shortcut key"); +} + +async function testOptions() { + const tool = toolbox.getPanel("options"); + panelWin = tool.panelWin; + const prefNodes = tool.panelDoc.querySelectorAll( + "input[type=checkbox][data-pref]" + ); + + // Store modified pref names so that they can be cleared on error. + for (const node of tool.panelDoc.querySelectorAll("[data-pref]")) { + const pref = node.getAttribute("data-pref"); + modifiedPrefs.push(pref); + } + + for (const node of prefNodes) { + const prefValue = GetPref(node.getAttribute("data-pref")); + + // Test clicking the checkbox for each options pref + await testMouseClick(node, prefValue); + + // Do again with opposite values to reset prefs + await testMouseClick(node, !prefValue); + } + + const prefSelects = tool.panelDoc.querySelectorAll("select[data-pref]"); + for (const node of prefSelects) { + await testSelect(node); + } +} + +async function testSelect(select) { + const pref = select.getAttribute("data-pref"); + const options = Array.from(select.options); + info("Checking select for: " + pref); + + is( + `${select.options[select.selectedIndex].value}`, + `${GetPref(pref)}`, + "select starts out selected" + ); + + for (const option of options) { + if (options.indexOf(option) === select.selectedIndex) { + continue; + } + + const observer = new PrefObserver("devtools."); + + let changeSeen = false; + const changeSeenPromise = new Promise(resolve => { + observer.once(pref, () => { + changeSeen = true; + is( + `${GetPref(pref)}`, + `${option.value}`, + "Preference been switched for " + pref + ); + resolve(); + }); + }); + + select.selectedIndex = options.indexOf(option); + const changeEvent = new Event("change"); + select.dispatchEvent(changeEvent); + + await changeSeenPromise; + + ok(changeSeen, "Correct pref was changed"); + observer.destroy(); + } +} + +async function testMouseClick(node, prefValue) { + const observer = new PrefObserver("devtools."); + + const pref = node.getAttribute("data-pref"); + let changeSeen = false; + const changeSeenPromise = new Promise(resolve => { + observer.once(pref, () => { + changeSeen = true; + is(GetPref(pref), !prefValue, "New value is correct for " + pref); + resolve(); + }); + }); + + node.scrollIntoView(); + + // We use executeSoon here to ensure that the element is in view and + // clickable. + executeSoon(function () { + info("Click event synthesized for pref " + pref); + EventUtils.synthesizeMouseAtCenter(node, {}, panelWin); + }); + + await changeSeenPromise; + + ok(changeSeen, "Correct pref was changed"); + observer.destroy(); +} + +async function testToggleWebExtensions() { + const disabledExtensions = new Set(); + const toggleableWebExtensions = toolbox.listWebExtensions(); + + function toggleWebExtension(node) { + node.scrollIntoView(); + EventUtils.synthesizeMouseAtCenter(node, {}, panelWin); + } + + function assertExpectedDisabledExtensions() { + for (const ext of toggleableWebExtensions) { + if (disabledExtensions.has(ext)) { + ok( + !toolbox.isWebExtensionEnabled(ext.uuid), + `The WebExtension "${ext.name}" should be disabled` + ); + } else { + ok( + toolbox.isWebExtensionEnabled(ext.uuid), + `The WebExtension "${ext.name}" should be enabled` + ); + } + } + } + + function assertAllExtensionsDisabled() { + const enabledUUIDs = toggleableWebExtensions + .filter(ext => toolbox.isWebExtensionEnabled(ext.uuid)) + .map(ext => ext.uuid); + + Assert.deepEqual( + enabledUUIDs, + [], + "All the registered WebExtensions should be disabled" + ); + } + + function assertAllExtensionsEnabled() { + const disabledUUIDs = toolbox + .listWebExtensions() + .filter(ext => !toolbox.isWebExtensionEnabled(ext.uuid)) + .map(ext => ext.uuid); + + Assert.deepEqual( + disabledUUIDs, + [], + "All the registered WebExtensions should be enabled" + ); + } + + function getWebExtensionNodes() { + const toolNodes = panelWin.document.querySelectorAll( + "#default-tools-box input[type=checkbox]:not([data-unsupported])," + + "#additional-tools-box input[type=checkbox]:not([data-unsupported])" + ); + + return [...toolNodes].filter(node => { + return toggleableWebExtensions.some( + ({ uuid }) => node.getAttribute("id") === `webext-${uuid}` + ); + }); + } + + let webExtensionNodes = getWebExtensionNodes(); + + is( + webExtensionNodes.length, + toggleableWebExtensions.length, + "There should be a toggle checkbox for every WebExtension registered" + ); + + for (const ext of toggleableWebExtensions) { + ok( + toolbox.isWebExtensionEnabled(ext.uuid), + `The WebExtension "${ext.name}" is initially enabled` + ); + } + + // Store modified pref names so that they can be cleared on error. + for (const ext of toggleableWebExtensions) { + modifiedPrefs.push(ext.pref); + } + + // Turn each registered WebExtension to disabled. + for (const node of webExtensionNodes) { + toggleWebExtension(node); + + const toggledExt = toggleableWebExtensions.find(ext => { + return node.id == `webext-${ext.uuid}`; + }); + ok(toggledExt, "Found a WebExtension for the checkbox element"); + disabledExtensions.add(toggledExt); + + assertExpectedDisabledExtensions(); + } + + assertAllExtensionsDisabled(); + + // Turn each registered WebExtension to enabled. + for (const node of webExtensionNodes) { + toggleWebExtension(node); + + const toggledExt = toggleableWebExtensions.find(ext => { + return node.id == `webext-${ext.uuid}`; + }); + ok(toggledExt, "Found a WebExtension for the checkbox element"); + disabledExtensions.delete(toggledExt); + + assertExpectedDisabledExtensions(); + } + + assertAllExtensionsEnabled(); + + // Unregister the WebExtensions one by one, and check that only the expected + // ones have been unregistered, and the remaining onea are still listed. + for (const ext of toggleableWebExtensions) { + ok( + !!toolbox.listWebExtensions().length, + "There should still be extensions registered" + ); + toolbox.unregisterWebExtension(ext.uuid); + + const registeredUUIDs = toolbox.listWebExtensions().map(item => item.uuid); + ok( + !registeredUUIDs.includes(ext.uuid), + `the WebExtension "${ext.name}" should have been unregistered` + ); + + webExtensionNodes = getWebExtensionNodes(); + + const checkboxEl = webExtensionNodes.find( + el => el.id === `webext-${ext.uuid}` + ); + is( + checkboxEl, + undefined, + "The unregistered WebExtension checkbox should have been removed" + ); + + is( + registeredUUIDs.length, + webExtensionNodes.length, + "There should be the expected number of WebExtensions checkboxes" + ); + } + + is( + toolbox.listWebExtensions().length, + 0, + "All WebExtensions have been unregistered" + ); + + webExtensionNodes = getWebExtensionNodes(); + + is( + webExtensionNodes.length, + 0, + "There should not be any checkbox for the unregistered WebExtensions" + ); +} + +function getToolNode(id) { + return panelWin.document.getElementById(id); +} + +async function testToggleTools() { + const toolNodes = panelWin.document.querySelectorAll( + "#default-tools-box input[type=checkbox]:not([data-unsupported])," + + "#additional-tools-box input[type=checkbox]:not([data-unsupported])" + ); + const toolNodeIds = [...toolNodes].map(node => node.id); + const enabledToolIds = [...toolNodes] + .filter(node => node.checked) + .map(node => node.id); + + const toggleableTools = gDevTools + .getDefaultTools() + .filter(tool => { + return tool.visibilityswitch; + }) + .concat(gDevTools.getAdditionalTools()) + .concat(toolbox.getAdditionalTools()); + + for (const node of toolNodes) { + const id = node.getAttribute("id"); + ok( + toggleableTools.some(tool => tool.id === id), + "There should be a toggle checkbox for: " + id + ); + } + + // Store modified pref names so that they can be cleared on error. + for (const tool of toggleableTools) { + const pref = tool.visibilityswitch; + modifiedPrefs.push(pref); + } + + // Toggle each tool + for (const id of toolNodeIds) { + await toggleTool(getToolNode(id)); + } + + // Toggle again to reset tool enablement state + for (const id of toolNodeIds) { + await toggleTool(getToolNode(id)); + } + + // Test that a tool can still be added when no tabs are present: + // Disable all tools + for (const id of enabledToolIds) { + await toggleTool(getToolNode(id)); + } + // Re-enable the tools which are enabled by default + for (const id of enabledToolIds) { + await toggleTool(getToolNode(id)); + } + + // Toggle first, middle, and last tools to ensure that toolbox tabs are + // inserted in order + const firstToolId = toolNodeIds[0]; + const middleToolId = toolNodeIds[(toolNodeIds.length / 2) | 0]; + const lastToolId = toolNodeIds[toolNodeIds.length - 1]; + + await toggleTool(getToolNode(firstToolId)); + await toggleTool(getToolNode(firstToolId)); + await toggleTool(getToolNode(middleToolId)); + await toggleTool(getToolNode(middleToolId)); + await toggleTool(getToolNode(lastToolId)); + await toggleTool(getToolNode(lastToolId)); +} + +/** + * Toggle tool node checkbox. Note: because toggling the checkbox will result in + * re-rendering of the tool list, we must re-query the checkboxes every time. + */ +async function toggleTool(node) { + const toolId = node.getAttribute("id"); + + const registeredPromise = new Promise(resolve => { + if (node.checked) { + gDevTools.once( + "tool-unregistered", + checkUnregistered.bind(null, toolId, resolve) + ); + } else { + gDevTools.once( + "tool-registered", + checkRegistered.bind(null, toolId, resolve) + ); + } + }); + node.scrollIntoView(); + EventUtils.synthesizeMouseAtCenter(node, {}, panelWin); + + await registeredPromise; +} + +function checkUnregistered(toolId, resolve, data) { + if (data == toolId) { + ok(true, "Correct tool removed"); + // checking tab on the toolbox + ok( + !doc.getElementById("toolbox-tab-" + toolId), + "Tab removed for " + toolId + ); + } else { + ok(false, "Something went wrong, " + toolId + " was not unregistered"); + } + resolve(); +} + +async function checkRegistered(toolId, resolve, data) { + if (data == toolId) { + ok(true, "Correct tool added back"); + // checking tab on the toolbox + const button = await lookupButtonForToolId(toolId); + ok(button, "Tab added back for " + toolId); + } else { + ok(false, "Something went wrong, " + toolId + " was not registered"); + } + resolve(); +} + +function GetPref(name) { + const type = Services.prefs.getPrefType(name); + switch (type) { + case Services.prefs.PREF_STRING: + return Services.prefs.getCharPref(name); + case Services.prefs.PREF_INT: + return Services.prefs.getIntPref(name); + case Services.prefs.PREF_BOOL: + return Services.prefs.getBoolPref(name); + default: + throw new Error("Unknown type"); + } +} + +/** + * Find the button from specified toolId. + * Generally, button which access to the tool panel is in toolbox or + * tools menu(in the Chevron menu). + */ +async function lookupButtonForToolId(toolId) { + let button = doc.getElementById("toolbox-tab-" + toolId); + if (!button) { + // search from the tools menu. + await openChevronMenu(toolbox); + button = doc.querySelector("#tools-chevron-menupopup-" + toolId); + + await closeChevronMenu(toolbox); + } + return button; +} + +async function cleanup() { + gDevTools.unregisterTool("testTool"); + await toolbox.destroy(); + gBrowser.removeCurrentTab(); + for (const pref of modifiedPrefs) { + Services.prefs.clearUserPref(pref); + } + toolbox = doc = panelWin = modifiedPrefs = null; +} diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_buttons.js b/devtools/client/framework/test/browser_toolbox_options_disable_buttons.js new file mode 100644 index 0000000000..417970bc07 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_buttons.js @@ -0,0 +1,270 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let TEST_URL = + "data:text/html;charset=utf8,test for dynamically " + + "registering and unregistering tools"; + +// The frames button is only shown if the page has at least one iframe so we +// need to add one to the test page. +TEST_URL += '<iframe src="data:text/plain,iframe"></iframe>'; +// The error count button is only shown if there are errors on the page +TEST_URL += '<script>console.error("err")</script>'; + +var modifiedPrefs = []; +registerCleanupFunction(() => { + for (const pref of modifiedPrefs) { + Services.prefs.clearUserPref(pref); + } +}); + +const TOGGLE_BUTTONS = [ + "command-button-measure", + "command-button-rulers", + "command-button-responsive", + "command-button-pick", +]; + +add_task(async function test() { + const tab = await addTab(TEST_URL); + let toolbox = await gDevTools.showToolboxForTab(tab); + const optionsPanelWin = await selectOptionsPanel(toolbox); + await testToggleToolboxButtons(toolbox, optionsPanelWin); + toolbox = await testPrefsAreRespectedWhenReopeningToolbox(); + await testButtonStateOnClick(toolbox); + + await toolbox.destroy(); +}); + +async function selectOptionsPanel(toolbox) { + info("Selecting the options panel"); + + const onOptionsSelected = toolbox.once("options-selected"); + toolbox.selectTool("options"); + const optionsPanel = await onOptionsSelected; + ok(true, "Options panel selected via selectTool method"); + return optionsPanel.panelWin; +} + +async function testToggleToolboxButtons(toolbox, optionsPanelWin) { + const checkNodes = [ + ...optionsPanelWin.document.querySelectorAll( + "#enabled-toolbox-buttons-box input[type=checkbox]" + ), + ]; + + // Filter out all the buttons which are not supported on the current target. + // (DevTools Experimental Preferences etc...) + const toolbarButtons = toolbox.toolbarButtons.filter(tool => + tool.isToolSupported(toolbox) + ); + + const visibleToolbarButtons = toolbarButtons.filter(tool => tool.isVisible); + + const toolbarButtonNodes = [ + ...toolbox.doc.querySelectorAll(".command-button"), + ]; + + is( + checkNodes.length, + toolbarButtons.length, + "All of the buttons are toggleable." + ); + is( + visibleToolbarButtons.length, + toolbarButtonNodes.length, + "All of the DOM buttons are toggleable." + ); + + for (const tool of toolbarButtons) { + const id = tool.id; + const matchedCheckboxes = checkNodes.filter(node => node.id === id); + const matchedButtons = toolbarButtonNodes.filter( + button => button.id === id + ); + if (tool.isVisible) { + is( + matchedCheckboxes.length, + 1, + "There should be a single toggle checkbox for: " + id + ); + is( + matchedCheckboxes[0].nextSibling.textContent, + tool.description, + "The label for checkbox matches the tool definition." + ); + is( + matchedButtons.length, + 1, + "There should be a DOM button for the visible: " + id + ); + + // The error count button title isn't its description + if (id !== "command-button-errorcount") { + is( + matchedButtons[0].getAttribute("title"), + tool.description, + "The tooltip for button matches the tool definition." + ); + } + + if (TOGGLE_BUTTONS.includes(id)) { + is( + matchedButtons[0].getAttribute("aria-pressed"), + "false", + `The aria-pressed attribute is set to false for ${id} button` + ); + } else { + is( + matchedButtons[0].getAttribute("aria-pressed"), + null, + `The ${id} button does not have the aria-pressed attribute` + ); + } + } else { + is( + matchedButtons.length, + 0, + "There should not be a DOM button for the invisible: " + id + ); + } + } + + // Store modified pref names so that they can be cleared on error. + for (const tool of toolbarButtons) { + const pref = tool.visibilityswitch; + modifiedPrefs.push(pref); + } + + // Try checking each checkbox, making sure that it changes the preference + for (const node of checkNodes) { + const tool = toolbarButtons.filter( + commandButton => commandButton.id === node.id + )[0]; + const isVisible = getBoolPref(tool.visibilityswitch); + + testPreferenceAndUIStateIsConsistent(toolbox, optionsPanelWin); + node.click(); + testPreferenceAndUIStateIsConsistent(toolbox, optionsPanelWin); + + const isVisibleAfterClick = getBoolPref(tool.visibilityswitch); + + is( + isVisible, + !isVisibleAfterClick, + "Clicking on the node should have toggled visibility preference for " + + tool.visibilityswitch + ); + + if (isVisibleAfterClick) { + const matchedButton = toolbox.doc.getElementById(tool.id); + if (TOGGLE_BUTTONS.includes(tool.id)) { + is( + matchedButton.getAttribute("aria-pressed"), + "false", + `The aria-pressed attribute is set to false for ${tool.id} button` + ); + } else { + is( + matchedButton.getAttribute("aria-pressed"), + null, + `The ${tool.id} button does not have the aria-pressed attribute` + ); + } + } + } +} + +async function testPrefsAreRespectedWhenReopeningToolbox() { + info("Closing toolbox to test after reopening"); + await gDevTools.closeToolboxForTab(gBrowser.selectedTab); + + const toolbox = await gDevTools.showToolboxForTab(gBrowser.selectedTab); + const optionsPanelWin = await selectOptionsPanel(toolbox); + + info("Toolbox has been reopened. Checking UI state."); + await testPreferenceAndUIStateIsConsistent(toolbox, optionsPanelWin); + return toolbox; +} + +function testPreferenceAndUIStateIsConsistent(toolbox, optionsPanelWin) { + const checkNodes = [ + ...optionsPanelWin.document.querySelectorAll( + "#enabled-toolbox-buttons-box input[type=checkbox]" + ), + ]; + const toolboxButtonNodes = [ + ...toolbox.doc.querySelectorAll(".command-button"), + ]; + + for (const tool of toolbox.toolbarButtons) { + const isVisible = getBoolPref(tool.visibilityswitch); + + const button = toolboxButtonNodes.find( + toolboxButton => toolboxButton.id === tool.id + ); + is(!!button, isVisible, "Button visibility matches pref for " + tool.id); + + const check = checkNodes.filter(node => node.id === tool.id)[0]; + if (check) { + is( + check.checked, + isVisible, + "Checkbox should be selected based on current pref for " + tool.id + ); + } + } +} + +async function testButtonStateOnClick(toolbox) { + const toolboxButtons = ["#command-button-rulers", "#command-button-measure"]; + for (const toolboxButton of toolboxButtons) { + const button = toolbox.doc.querySelector(toolboxButton); + if (!button) { + ok(false, `Couldn't find ${toolboxButton}`); + continue; + } + + const isChecked = waitUntil(() => button.classList.contains("checked")); + is( + button.getAttribute("aria-pressed"), + "false", + `${toolboxButton} has aria-pressed set to false when it's off` + ); + + button.click(); + await isChecked; + ok( + button.classList.contains("checked"), + `Button for ${toolboxButton} can be toggled on` + ); + is( + button.getAttribute("aria-pressed"), + "true", + `${toolboxButton} has aria-pressed set to true when it's on` + ); + + const isUnchecked = waitUntil(() => !button.classList.contains("checked")); + button.click(); + await isUnchecked; + ok( + !button.classList.contains("checked"), + `Button for ${toolboxButton} can be toggled off` + ); + is( + button.getAttribute("aria-pressed"), + "false", + `aria-pressed is set back to false on ${toolboxButton} after it has been toggled off` + ); + } +} + +function getBoolPref(key) { + try { + return Services.prefs.getBoolPref(key); + } catch (e) { + return false; + } +} diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_cache-01.js b/devtools/client/framework/test/browser_toolbox_options_disable_cache-01.js new file mode 100644 index 0000000000..77c2b1bccb --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_cache-01.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Tests that disabling the cache for a tab works as it should when toolboxes +// are not toggled. +/* import-globals-from helper_disable_cache.js */ +loadHelperScript("helper_disable_cache.js"); + +add_task(async function () { + // Disable rcwn to make cache behavior deterministic. + await pushPref("network.http.rcwn.enabled", false); + + // Ensure that the setting is cleared after the test. + registerCleanupFunction(() => { + info("Resetting devtools.cache.disabled to false."); + Services.prefs.setBoolPref("devtools.cache.disabled", false); + }); + + // Initialise tabs: 1 and 2 with a toolbox, 3 and 4 without. + for (const tab of tabs) { + await initTab(tab, tab.startToolbox); + } + + // Ensure cache is enabled for all tabs. + await checkCacheStateForAllTabs([true, true, true, true]); + + // Check the checkbox in tab 0 and ensure cache is disabled for tabs 0 and 1. + await setDisableCacheCheckboxChecked(tabs[0], true); + await checkCacheStateForAllTabs([false, false, true, true]); + + await finishUp(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_cache-02.js b/devtools/client/framework/test/browser_toolbox_options_disable_cache-02.js new file mode 100644 index 0000000000..235893ba60 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_cache-02.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Tests that disabling the cache for a tab works as it should when toolboxes +// are toggled. +/* import-globals-from helper_disable_cache.js */ +loadHelperScript("helper_disable_cache.js"); + +add_task(async function () { + // Disable rcwn to make cache behavior deterministic. + await pushPref("network.http.rcwn.enabled", false); + + // Ensure that the setting is cleared after the test. + registerCleanupFunction(() => { + info("Resetting devtools.cache.disabled to false."); + Services.prefs.setBoolPref("devtools.cache.disabled", false); + }); + + // Initialise tabs: 1 and 2 with a toolbox, 3 and 4 without. + for (const tab of tabs) { + await initTab(tab, tab.startToolbox); + } + + // Disable cache in tab 0 + await setDisableCacheCheckboxChecked(tabs[0], true); + + // Open toolbox in tab 2 and ensure the cache is then disabled. + tabs[2].toolbox = await gDevTools.showToolboxForTab(tabs[2].tab, { + toolId: "options", + }); + await checkCacheEnabled(tabs[2], false); + + // Close toolbox in tab 2 and ensure the cache is enabled again + await tabs[2].toolbox.destroy(); + await checkCacheEnabled(tabs[2], true); + + // Open toolbox in tab 2 and ensure the cache is then disabled. + tabs[2].toolbox = await gDevTools.showToolboxForTab(tabs[2].tab, { + toolId: "options", + }); + await checkCacheEnabled(tabs[2], false); + + // Check the checkbox in tab 2 and ensure cache is enabled for all tabs. + await setDisableCacheCheckboxChecked(tabs[2], false); + await checkCacheStateForAllTabs([true, true, true, true]); + + await finishUp(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_cache-03.js b/devtools/client/framework/test/browser_toolbox_options_disable_cache-03.js new file mode 100644 index 0000000000..f3a53f6422 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_cache-03.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that even when the cache is disabled, the inspector/styleeditor don't fetch again +// stylesheets from the server to display them in devtools, but use the cached version. + +const TEST_CSS = URL_ROOT + "browser_toolbox_options_disable_cache.css.sjs"; +const TEST_PAGE = `<html> + <head> + <meta charset="utf-8"/> + <link href="${TEST_CSS}" rel="stylesheet" type="text/css"/> + </head> + <body></body> +</html>`; + +add_task(async function () { + info("Setup preferences for testing"); + // Disable rcwn to make cache behavior deterministic. + await pushPref("network.http.rcwn.enabled", false); + // Disable the cache. + await pushPref("devtools.cache.disabled", true); + + info("Open inspector"); + const toolbox = await openNewTabAndToolbox( + `data:text/html;charset=UTF-8,${encodeURIComponent(TEST_PAGE)}`, + "inspector" + ); + const inspector = toolbox.getPanel("inspector"); + + info( + "Check that the CSS content loaded in the page " + + "and the one shown in the inspector are the same" + ); + const webContent = await getWebContent(); + const inspectorContent = await getInspectorContent(inspector); + is( + webContent, + inspectorContent, + "The contents of both web and DevTools are same" + ); + + await closeTabAndToolbox(); +}); + +async function getInspectorContent(inspector) { + const ruleView = inspector.getPanel("ruleview").view; + const valueEl = await waitFor(() => + ruleView.styleDocument.querySelector(".ruleview-propertyvalue") + ); + return valueEl.textContent; +} + +async function getWebContent() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const doc = content.document; + return doc.ownerGlobal.getComputedStyle(doc.body, "::before").content; + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_cache.css.sjs b/devtools/client/framework/test/browser_toolbox_options_disable_cache.css.sjs new file mode 100644 index 0000000000..0b5932ca02 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_cache.css.sjs @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(request, response) { + // This returns always new and different CSS content. + const page = `body::before { content: "${Date.now()}"; }`; + response.setHeader("Content-Type", "text/css; charset=utf-8", false); + response.setHeader("Content-Length", page.length + "", false); + response.write(page); +} diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_cache.sjs b/devtools/client/framework/test/browser_toolbox_options_disable_cache.sjs new file mode 100644 index 0000000000..dc67043be1 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_cache.sjs @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(request, response) { + const Etag = '"4d881ab-b03-435f0a0f9ef00"'; + const IfNoneMatch = request.hasHeader("If-None-Match") + ? request.getHeader("If-None-Match") + : ""; + + const guid = "xxxxxxxx-xxxx-xxxx-yxxx-xxxxxxxxxxxx".replace( + /[xy]/g, + function (c) { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + + return v.toString(16); + } + ); + + const page = "<!DOCTYPE html><html><body><h1>" + guid + "</h1></body></html>"; + + response.setHeader("Etag", Etag, false); + + if (IfNoneMatch === Etag) { + response.setStatusLine(request.httpVersion, "304", "Not Modified"); + } else { + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader("Content-Length", page.length + "", false); + response.write(page); + } +} diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_js.html b/devtools/client/framework/test/browser_toolbox_options_disable_js.html new file mode 100644 index 0000000000..a8c70c2e4d --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_js.html @@ -0,0 +1,47 @@ +<!DOCTYPE html> +<html> + <head> + <title>browser_toolbox_options_disablejs.html</title> + <meta charset="UTF-8"> + <style> + div { + width: 260px; + height: 24px; + border: 1px solid #000; + margin-top: 10px; + } + + iframe { + height: 90px; + border: 1px solid #000; + } + + h1 { + font-size: 20px + } + </style> + <script type="application/javascript"> + /* exported log */ + function log(msg) { + const output = document.getElementById("output"); + + output.innerHTML = msg; + } + </script> + </head> + <body> + <h1>Test in page</h1> + <input id="logJSEnabled" + type="button" + value="Log JS Enabled" + onclick="log('JavaScript Enabled')"/> + <input id="logJSDisabled" + type="button" + value="Log JS Disabled" + onclick="log('JavaScript Disabled')"/> + <br> + <div id="output">No output</div> + <h1>Test in iframe</h1> + <iframe src="browser_toolbox_options_disable_js_iframe.html"></iframe> + </body> +</html> diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_js.js b/devtools/client/framework/test/browser_toolbox_options_disable_js.js new file mode 100644 index 0000000000..4f08838c9f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_js.js @@ -0,0 +1,141 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that disabling JavaScript for a tab works as it should. + +const TEST_URI = URL_ROOT_SSL + "browser_toolbox_options_disable_js.html"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + // Start on the options panel from where we will toggle the disabling javascript + // option. + const toolbox = await gDevTools.showToolboxForTab(tab, { toolId: "options" }); + + await testJSEnabled(); + await testJSEnabledIframe(); + + // Disable JS. + await toggleJS(toolbox); + + await testJSDisabled(); + await testJSDisabledIframe(); + + // Navigate and check JS is still disabled + for (let i = 0; i < 10; i++) { + await navigateTo(`${TEST_URI}?nocache=${i}`); + await testJSDisabled(); + await testJSDisabledIframe(); + } + + // Re-enable JS. + await toggleJS(toolbox); + + await testJSEnabled(); + await testJSEnabledIframe(); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function testJSEnabled() { + info("Testing that JS is enabled"); + + // We use waitForTick here because switching browsingContext.allowJavascript + // to true takes a while to become live. + await waitForTick(); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const doc = content.document; + const output = doc.getElementById("output"); + doc.querySelector("#logJSEnabled").click(); + is( + output.textContent, + "JavaScript Enabled", + 'Output is "JavaScript Enabled"' + ); + }); +} + +async function testJSEnabledIframe() { + info("Testing that JS is enabled in the iframe"); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const doc = content.document; + const iframe = doc.querySelector("iframe"); + const iframeDoc = iframe.contentDocument; + const output = iframeDoc.getElementById("output"); + iframeDoc.querySelector("#logJSEnabled").click(); + is( + output.textContent, + "JavaScript Enabled", + 'Output is "JavaScript Enabled" in iframe' + ); + }); +} + +async function toggleJS(toolbox) { + const panel = toolbox.getCurrentPanel(); + const cbx = panel.panelDoc.getElementById("devtools-disable-javascript"); + + if (cbx.checked) { + info("Clearing checkbox to re-enable JS"); + } else { + info("Checking checkbox to disable JS"); + } + + let javascriptEnabled = + await toolbox.commands.targetConfigurationCommand.isJavascriptEnabled(); + is( + javascriptEnabled, + !cbx.checked, + "targetConfigurationCommand.isJavascriptEnabled is correct before the toggle" + ); + + const browserLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + cbx.click(); + await browserLoaded; + + javascriptEnabled = + await toolbox.commands.targetConfigurationCommand.isJavascriptEnabled(); + is( + javascriptEnabled, + !cbx.checked, + "targetConfigurationCommand.isJavascriptEnabled is correctly updated" + ); +} + +async function testJSDisabled() { + info("Testing that JS is disabled"); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const doc = content.document; + const output = doc.getElementById("output"); + doc.querySelector("#logJSDisabled").click(); + + Assert.notStrictEqual( + output.textContent, + "JavaScript Disabled", + 'output is not "JavaScript Disabled"' + ); + }); +} + +async function testJSDisabledIframe() { + info("Testing that JS is disabled in the iframe"); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const doc = content.document; + const iframe = doc.querySelector("iframe"); + const iframeDoc = iframe.contentDocument; + const output = iframeDoc.getElementById("output"); + iframeDoc.querySelector("#logJSDisabled").click(); + Assert.notStrictEqual( + output.textContent, + "JavaScript Disabled", + 'output is not "JavaScript Disabled" in iframe' + ); + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_js_iframe.html b/devtools/client/framework/test/browser_toolbox_options_disable_js_iframe.html new file mode 100644 index 0000000000..5006d24c9a --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_js_iframe.html @@ -0,0 +1,34 @@ +<html> + <head> + <title>browser_toolbox_options_disablejs.html</title> + <meta charset="UTF-8"> + <style> + div { + width: 260px; + height: 24px; + border: 1px solid #000; + margin-top: 10px; + } + </style> + <script type="application/javascript"> + /* exported log */ + function log(msg) { + const output = document.getElementById("output"); + + output.innerHTML = msg; + } + </script> + </head> + <body> + <input id="logJSEnabled" + type="button" + value="Log JS Enabled" + onclick="log('JavaScript Enabled')"/> + <input id="logJSDisabled" + type="button" + value="Log JS Disabled" + onclick="log('JavaScript Disabled')"/> + <br> + <div id="output">No output</div> + </body> +</html> diff --git a/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.html b/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.html new file mode 100644 index 0000000000..4065aabc2b --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.html @@ -0,0 +1,81 @@ +<!DOCTYPE html> +<html> + <head> + <title>browser_toolbox_options_enable_serviceworkers_testing.html</title> + <meta charset="UTF-8"> + </head> + <body> + <h1>SW-test</h1> + <script> + function register() { + return Promise.resolve().then(function() { + // While ServiceWorkerContainer.register() returns a promise, it's + // still wrapped with a .then() because navigator.serviceWorker is not + // defined in insecure contexts unless service worker testing is + // enabled, so dereferencing it would throw a ReferenceError (which + // is then caught in the .catch() clause). + return window.navigator.serviceWorker.register("serviceworker.js"); + }).then(registration => { + return {success: true}; + }).catch(error => { + return {success: false}; + }); + } + + function unregister() { + return Promise.resolve().then(function() { + return window.navigator.serviceWorker.getRegistration(); + }).then(registration => { + return registration.unregister().then(result => { + return {success: !!result}; + }); + }).catch(_ => { + return {success: false}; + }); + } + + function iframeRegisterAndUnregister() { + var frame = window.document.createElement("iframe"); + var promise = new Promise(function(resolve, reject) { + frame.addEventListener("load", function() { + Promise.resolve().then(_ => { + return frame.contentWindow.navigator.serviceWorker.register("serviceworker.js"); + }).then(swr => { + return swr.unregister(); + }).then(_ => { + frame.remove(); + resolve({success: true}); + }).catch(error => { + resolve({success: false}); + }); + }, {once: true}); + }); + frame.src = "browser_toolbox_options_enabled_serviceworkers_testing.html"; + window.document.body.appendChild(frame); + return promise; + } + + window.addEventListener("message", function(event) { + var response; + switch (event.data) { + case "devtools:sw-test:register": { + response = register(); + break; + } + case "devtools:sw-test:unregister": { + response = unregister(); + break; + } + case "devtools:sw-test:iframe:register-and-unregister": { + response = iframeRegisterAndUnregister(); + break; + } + } + response.then(data => { + event.ports[0].postMessage(data); + event.ports[0].close(); + }); + }); + </script> + </body> +</html> diff --git a/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.js b/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.js new file mode 100644 index 0000000000..152f64f835 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that enabling Service Workers testing option enables the +// mServiceWorkersTestingEnabled attribute added to nsPIDOMWindow. + +// We explicitly want to test that service worker testing allows to use service +// workers on non-https, so we use mochi.test:8888 to avoid the automatic upgrade +// to https when dom.security.https_first is true. +const TEST_URI = + URL_ROOT_MOCHI_8888 + + "browser_toolbox_options_enable_serviceworkers_testing.html"; +const ELEMENT_ID = "devtools-enable-serviceWorkersTesting"; + +add_task(async function () { + await pushPref("dom.serviceWorkers.exemptFromPerDomainMax", true); + await pushPref("dom.serviceWorkers.enabled", true); + await pushPref("dom.serviceWorkers.testing.enabled", false); + // Force the test to start without service worker testing enabled + await pushPref("devtools.serviceWorkers.testing.enabled", false); + + const tab = await addTab(TEST_URI); + const toolbox = await openToolboxForTab(tab, "options"); + + let data = await register(); + is(data.success, false, "Register should fail with security error"); + + const panel = toolbox.getCurrentPanel(); + const cbx = panel.panelDoc.getElementById(ELEMENT_ID); + is(cbx.checked, false, "The checkbox shouldn't be checked"); + + info(`Checking checkbox to enable service workers testing`); + cbx.scrollIntoView(); + cbx.click(); + + await reloadBrowser(); + + data = await register(); + is(data.success, true, "Register should success"); + + await unregister(); + data = await registerAndUnregisterInFrame(); + is(data.success, true, "Register should success"); + + info("Workers should be turned back off when we closes the toolbox"); + await toolbox.destroy(); + + await reloadBrowser(); + data = await register(); + is(data.success, false, "Register should fail with security error"); +}); + +function sendMessage(name) { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [name], nameChild => { + return new Promise(resolve => { + const channel = new content.MessageChannel(); + content.postMessage(nameChild, "*", [channel.port2]); + channel.port1.onmessage = function (msg) { + resolve(msg.data); + channel.port1.close(); + }; + }); + }); +} + +function register() { + return sendMessage("devtools:sw-test:register"); +} + +function unregister(swr) { + return sendMessage("devtools:sw-test:unregister"); +} + +function registerAndUnregisterInFrame() { + return sendMessage("devtools:sw-test:iframe:register-and-unregister"); +} diff --git a/devtools/client/framework/test/browser_toolbox_options_frames_button.js b/devtools/client/framework/test/browser_toolbox_options_frames_button.js new file mode 100644 index 0000000000..50adeda39b --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_frames_button.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the frames button is always visible when the user is on the options panel. +// Test that the button is disabled if the current target has no frames. +// Test that the button is enabled otherwise. + +const TEST_URL = "data:text/html;charset=utf8,test frames button visibility"; +const TEST_IFRAME_URL = "data:text/plain,iframe"; +const TEST_IFRAME_URL2 = "data:text/plain,iframe2"; +const TEST_URL_FRAMES = + TEST_URL + + `<iframe src="${TEST_IFRAME_URL}"></iframe>` + + `<iframe src="${TEST_IFRAME_URL2}"></iframe>`; +const FRAME_BUTTON_PREF = "devtools.command-button-frames.enabled"; + +add_task(async function () { + // Hide the button by default. + await pushPref(FRAME_BUTTON_PREF, false); + + const tab = await addTab(TEST_URL); + + info("Open the toolbox on the Options panel"); + const toolbox = await gDevTools.showToolboxForTab(tab, { toolId: "options" }); + const doc = toolbox.doc; + + const optionsPanel = toolbox.getCurrentPanel(); + + let framesButton = doc.getElementById("command-button-frames"); + ok(!framesButton, "Frames button is not rendered."); + + const optionsDoc = optionsPanel.panelWin.document; + const framesButtonCheckbox = optionsDoc.getElementById( + "command-button-frames" + ); + framesButtonCheckbox.click(); + + info("Wait for the frame button to be rendered"); + framesButton = await waitFor(() => + doc.getElementById("command-button-frames") + ); + ok(framesButton.disabled, "Frames button is disabled."); + + info("Leave the options panel, the frames button should not be rendered."); + await toolbox.selectTool("webconsole"); + framesButton = doc.getElementById("command-button-frames"); + ok(!framesButton, "Frames button is no longer rendered."); + + info("Go back to the options panel, the frames button should rendered."); + await toolbox.selectTool("options"); + framesButton = doc.getElementById("command-button-frames"); + ok(framesButton, "Frames button is rendered again."); + + // Do not run the rest of this test when both fission and EFT is disabled as + // it prevents creating a target for the iframe + if (!isFissionEnabled() || !isEveryFrameTargetEnabled()) { + return; + } + + info("Navigate to a page with frames, the frames button should be enabled."); + await navigateTo(TEST_URL_FRAMES); + + framesButton = doc.getElementById("command-button-frames"); + ok(framesButton, "Frames button is still rendered."); + + await waitFor(() => { + framesButton = doc.getElementById("command-button-frames"); + return framesButton && !framesButton.disabled; + }); + + const { targetCommand } = toolbox.commands; + const iframeTarget = targetCommand + .getAllTargets([targetCommand.TYPES.FRAME]) + .find(target => target.url == TEST_IFRAME_URL); + ok(iframeTarget, "Found the target for the iframe"); + + ok( + !framesButton.classList.contains("checked"), + "Before selecting an iframe, the button is not checked" + ); + await toolbox.commands.targetCommand.selectTarget(iframeTarget); + ok( + framesButton.classList.contains("checked"), + "After selecting an iframe, the button is checked" + ); + + info("Remove this first iframe, which is currently selected"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + content.document.querySelector("iframe").remove(); + }); + + await waitFor(() => { + return targetCommand.selectedTargetFront == targetCommand.targetFront; + }, "Wait for the selected target to be back on the top target"); + + ok( + !framesButton.classList.contains("checked"), + "The button is back unchecked after having removed the selected iframe" + ); + + Services.prefs.clearUserPref(FRAME_BUTTON_PREF); +}); diff --git a/devtools/client/framework/test/browser_toolbox_options_multiple_tabs.js b/devtools/client/framework/test/browser_toolbox_options_multiple_tabs.js new file mode 100644 index 0000000000..74c0983d4e --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_multiple_tabs.js @@ -0,0 +1,130 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = + "data:text/html;charset=utf8,test for dynamically registering " + + "and unregistering tools across multiple tabs"; + +let tab1, tab2, modifiedPref; + +add_task(async function () { + tab1 = await openToolboxOptionsInNewTab(); + tab2 = await openToolboxOptionsInNewTab(); + + await testToggleTools(); + await cleanup(); +}); + +async function openToolboxOptionsInNewTab() { + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab); + const doc = toolbox.doc; + const panel = await toolbox.selectTool("options"); + const { id } = panel.panelDoc.querySelector( + "#default-tools-box input[type=checkbox]:not([data-unsupported], [checked])" + ); + + return { + tab, + toolbox, + doc, + panelWin: panel.panelWin, + // This is a getter becuse toolbox tools list gets re-setup every time there + // is a tool-registered or tool-undregistered event. + get checkbox() { + return panel.panelDoc.getElementById(id); + }, + }; +} + +async function testToggleTools() { + is(tab1.checkbox.id, tab2.checkbox.id, "Default tool box should be in sync."); + + const toolId = tab1.checkbox.id; + const testTool = gDevTools.getDefaultTools().find(tool => tool.id === toolId); + // Store modified pref names so that they can be cleared on error. + modifiedPref = testTool.visibilityswitch; + + info(`Registering tool ${toolId} in the first tab.`); + await toggleTool(tab1, toolId); + + info(`Unregistering tool ${toolId} in the first tab.`); + await toggleTool(tab1, toolId); + + info(`Registering tool ${toolId} in the second tab.`); + await toggleTool(tab2, toolId); + + info(`Unregistering tool ${toolId} in the second tab.`); + await toggleTool(tab2, toolId); + + info(`Registering tool ${toolId} in the first tab.`); + await toggleTool(tab1, toolId); + + info(`Unregistering tool ${toolId} in the second tab.`); + await toggleTool(tab2, toolId); +} + +async function toggleTool({ doc, panelWin, checkbox, tab }, toolId) { + const prevChecked = checkbox.checked; + + (prevChecked ? checkRegistered : checkUnregistered)(toolId); + + const onToggleTool = gDevTools.once( + `tool-${prevChecked ? "unregistered" : "registered"}` + ); + EventUtils.sendMouseEvent({ type: "click" }, checkbox, panelWin); + const id = await onToggleTool; + + is(id, toolId, `Correct event for ${toolId} was fired`); + // await new Promise(resolve => setTimeout(resolve, 60000)); + (prevChecked ? checkUnregistered : checkRegistered)(toolId); +} + +async function checkUnregistered(toolId) { + ok( + !getToolboxTab(tab1.doc, toolId), + `Tab for unregistered tool ${toolId} is not present in first toolbox` + ); + ok( + !tab1.checkbox.checked, + `Checkbox for unregistered tool ${toolId} is not checked in first toolbox` + ); + ok( + !getToolboxTab(tab2.doc, toolId), + `Tab for unregistered tool ${toolId} is not present in second toolbox` + ); + ok( + !tab2.checkbox.checked, + `Checkbox for unregistered tool ${toolId} is not checked in second toolbox` + ); +} + +function checkRegistered(toolId) { + ok( + getToolboxTab(tab1.doc, toolId), + `Tab for registered tool ${toolId} is present in first toolbox` + ); + ok( + tab1.checkbox.checked, + `Checkbox for registered tool ${toolId} is checked in first toolbox` + ); + ok( + getToolboxTab(tab2.doc, toolId), + `Tab for registered tool ${toolId} is present in second toolbox` + ); + ok( + tab2.checkbox.checked, + `Checkbox for registered tool ${toolId} is checked in second toolbox` + ); +} + +async function cleanup() { + await tab1.toolbox.destroy(); + await tab2.toolbox.destroy(); + gBrowser.removeCurrentTab(); + gBrowser.removeCurrentTab(); + Services.prefs.clearUserPref(modifiedPref); + tab1 = tab2 = modifiedPref = null; +} diff --git a/devtools/client/framework/test/browser_toolbox_options_panel_toggle.js b/devtools/client/framework/test/browser_toolbox_options_panel_toggle.js new file mode 100644 index 0000000000..d94f7c14fb --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_panel_toggle.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether options panel toggled by key event and "Settings" on the meatball menu. + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + const tab = await addTab("about:blank"); + const toolbox = await openToolboxForTab( + tab, + "webconsole", + Toolbox.HostType.BOTTOM + ); + + info("Check the option panel was selected after sending F1 key event"); + await sendOptionsKeyEvent(toolbox); + is(toolbox.currentToolId, "options", "The options panel should be selected"); + + info("Check the last selected panel was selected after sending F1 key event"); + await sendOptionsKeyEvent(toolbox); + is( + toolbox.currentToolId, + "webconsole", + "The webconsole panel should be selected" + ); + + info("Check the option panel was selected after clicking 'Settings' menu"); + await clickSettingsMenu(toolbox); + is(toolbox.currentToolId, "options", "The options panel should be selected"); + + info( + "Check the last selected panel was selected after clicking 'Settings' menu" + ); + await sendOptionsKeyEvent(toolbox); + is( + toolbox.currentToolId, + "webconsole", + "The webconsole panel should be selected" + ); + + info("Check the combination of key event and 'Settings' menu"); + await sendOptionsKeyEvent(toolbox); + await clickSettingsMenu(toolbox); + is( + toolbox.currentToolId, + "webconsole", + "The webconsole panel should be selected" + ); + await clickSettingsMenu(toolbox); + await sendOptionsKeyEvent(toolbox); + is( + toolbox.currentToolId, + "webconsole", + "The webconsole panel should be selected" + ); +}); + +async function sendOptionsKeyEvent(toolbox) { + const onReady = toolbox.once("select"); + EventUtils.synthesizeKey("VK_F1", {}, toolbox.win); + await onReady; +} + +async function clickSettingsMenu(toolbox) { + const onPopupShown = () => { + toolbox.doc.removeEventListener("popupshown", onPopupShown); + const menuItem = toolbox.doc.getElementById( + "toolbox-meatball-menu-settings" + ); + EventUtils.synthesizeMouseAtCenter(menuItem, {}, menuItem.ownerGlobal); + }; + toolbox.doc.addEventListener("popupshown", onPopupShown); + + const button = toolbox.doc.getElementById("toolbox-meatball-menu-button"); + await waitUntil(() => button.style.pointerEvents !== "none"); + EventUtils.synthesizeMouseAtCenter(button, {}, button.ownerGlobal); + + await toolbox.once("select"); +} diff --git a/devtools/client/framework/test/browser_toolbox_popups_debugging.js b/devtools/client/framework/test/browser_toolbox_popups_debugging.js new file mode 100644 index 0000000000..28b2603e80 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_popups_debugging.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test opening toolboxes against a tab and its popup + +const TEST_URL = "data:text/html,test for debugging popups"; +const POPUP_URL = "data:text/html,popup"; + +const POPUP_DEBUG_PREF = "devtools.popups.debug"; + +add_task(async function () { + const isPopupDebuggingEnabled = Services.prefs.getBoolPref(POPUP_DEBUG_PREF); + + info("Open a tab and debug it"); + const tab = await addTab(TEST_URL); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + }); + + info("Open a popup"); + const onTabOpened = once(gBrowser.tabContainer, "TabOpen"); + const onToolboxSwitchedToTab = toolbox.once("switched-host-to-tab"); + await SpecialPowers.spawn(tab.linkedBrowser, [POPUP_URL], url => { + content.open(url); + }); + const tabOpenEvent = await onTabOpened; + const popupTab = tabOpenEvent.target; + + const popupToolbox = await gDevTools.showToolboxForTab(popupTab); + if (isPopupDebuggingEnabled) { + ok( + !popupToolbox, + "When popup debugging is enabled, the popup should be debugged via the same toolbox as the original tab" + ); + info("Wait for internal event notifying about the toolbox being moved"); + await onToolboxSwitchedToTab; + const browserContainer = gBrowser.getBrowserContainer( + popupTab.linkedBrowser + ); + const iframe = browserContainer.querySelector( + ".devtools-toolbox-bottom-iframe" + ); + ok(iframe, "The original tab's toolbox moved to the popup tab"); + } else { + ok(popupToolbox, "We were able to spawn a toolbox for the popup"); + info("Close the popup toolbox and its tab"); + await popupToolbox.destroy(); + } + + info("Close the popup tab"); + gBrowser.removeCurrentTab(); + + info("Close the original tab toolbox and itself"); + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_races.js b/devtools/client/framework/test/browser_toolbox_races.js new file mode 100644 index 0000000000..ede038e716 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_races.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Toggling the toolbox three time can take more than 45s on slow test machine +requestLongerTimeout(2); + +// Test toggling the toolbox quickly and see if there is any race breaking it. + +const URL = "data:text/html;charset=utf-8,Toggling devtools quickly"; +const { + gDevToolsBrowser, +} = require("resource://devtools/client/framework/devtools-browser.js"); + +add_task(async function () { + // Make sure this test starts with the selectedTool pref cleared. Previous + // tests select various tools, and that sets this pref. + Services.prefs.clearUserPref("devtools.toolbox.selectedTool"); + + await addTab(URL); + + let ready = 0, + destroy = 0, + destroyed = 0; + const onReady = () => { + ready++; + }; + const onDestroy = () => { + destroy++; + }; + const onDestroyed = () => { + destroyed++; + }; + gDevTools.on("toolbox-ready", onReady); + gDevTools.on("toolbox-destroy", onDestroy); + gDevTools.on("toolbox-destroyed", onDestroyed); + + // The current implementation won't toggle the toolbox many times, + // instead it will ignore toggles that happens while the toolbox is still + // creating or still destroying. + + info("Toggle the toolbox many times in a row"); + toggle(); + toggle(); + toggle(); + toggle(); + toggle(); + await wait(500); + + await waitFor(() => ready == 1); + is( + ready, + 1, + "No matter how many times we called toggle, it will only open the toolbox once" + ); + is( + destroy, + 0, + "All subsequent, synchronous call to toggle will be ignored and the toolbox won't be destroyed" + ); + is(destroyed, 0); + + info("Retoggle the toolbox many times in a row"); + toggle(); + toggle(); + toggle(); + toggle(); + toggle(); + await wait(500); + + await waitFor(() => destroyed == 1); + is(destroyed, 1, "Similarly, the toolbox will be closed"); + is(destroy, 1); + is( + ready, + 1, + "and no other toolbox will be opened. The subsequent toggle will be ignored." + ); + + gDevTools.off("toolbox-ready", onReady); + gDevTools.off("toolbox-destroy", onDestroy); + gDevTools.off("toolbox-destroyed", onDestroyed); + await wait(1000); + + gBrowser.removeCurrentTab(); +}); + +function toggle() { + // When enabling the input event prioritization, we'll reserve some time to + // process input events in each frame. In that case, the synthesized input + // events may delay the normal events. Replace synthesized key events by + // toggleToolboxCommand to prevent the synthesized input events jam the + // content process and cause the test timeout. + gDevToolsBrowser.toggleToolboxCommand(window.gBrowser); +} diff --git a/devtools/client/framework/test/browser_toolbox_raise.js b/devtools/client/framework/test/browser_toolbox_raise.js new file mode 100644 index 0000000000..1912d349d4 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_raise.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = "data:text/html,test for opening toolbox in different hosts"; + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + const tab1 = await addTab(TEST_URL); + const tab2 = BrowserTestUtils.addTab(gBrowser); + + const toolbox = await gDevTools.showToolboxForTab(tab1); + await testBottomHost(toolbox, tab1, tab2); + + await testWindowHost(toolbox); + + Services.prefs.setCharPref("devtools.toolbox.host", Toolbox.HostType.BOTTOM); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); + gBrowser.removeCurrentTab(); +}); + +async function testBottomHost(toolbox, tab1, tab2) { + // switch to another tab and test toolbox.raise() + gBrowser.selectedTab = tab2; + await new Promise(executeSoon); + is( + gBrowser.selectedTab, + tab2, + "Correct tab is selected before calling raise" + ); + + await toolbox.raise(); + is( + gBrowser.selectedTab, + tab1, + "Correct tab was selected after calling raise" + ); +} + +async function testWindowHost(toolbox) { + await toolbox.switchHost(Toolbox.HostType.WINDOW); + + info("Wait for the toolbox to be focused when switching to window host"); + // We can't wait for the "focus" event on toolbox.win.parent as this document is created while calling switchHost. + await waitFor(() => { + return Services.focus.activeWindow == toolbox.topWindow; + }); + + const onBrowserWindowFocused = new Promise(resolve => + window.addEventListener("focus", resolve, { once: true, capture: true }) + ); + + info("Focusing the browser window"); + window.focus(); + + info("Wait for the browser window to be focused"); + await onBrowserWindowFocused; + + // Now raise toolbox. + await toolbox.raise(); + is( + Services.focus.activeWindow, + toolbox.topWindow, + "the toolbox window is immediately focused after raise resolution" + ); +} diff --git a/devtools/client/framework/test/browser_toolbox_ready.js b/devtools/client/framework/test/browser_toolbox_ready.js new file mode 100644 index 0000000000..5d7d6be258 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_ready.js @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = "data:text/html,test for toolbox being ready"; + +add_task(async function () { + const tab = await addTab(TEST_URL); + + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + }); + ok(toolbox.isReady, "toolbox isReady is set"); + ok(toolbox.threadFront, "toolbox has a thread front"); + + const toolbox2 = await gDevTools.showToolboxForTab(tab, { + toolId: toolbox.toolId, + }); + is(toolbox2, toolbox, "same toolbox"); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_remoteness_change.js b/devtools/client/framework/test/browser_toolbox_remoteness_change.js new file mode 100644 index 0000000000..af5f105214 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_remoteness_change.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const URL_1 = "about:robots"; +const URL_2 = + "data:text/html;charset=UTF-8," + + encodeURIComponent('<div id="remote-page">foo</div>'); + +// Testing navigation between processes +add_task(async function () { + info(`Testing navigation between processes`); + + info("Open a tab on a URL supporting only running in parent process"); + const tab = await addTab(URL_1); + is( + tab.linkedBrowser.currentURI.spec, + URL_1, + "We really are on the expected document" + ); + is( + tab.linkedBrowser.getAttribute("remote"), + "", + "And running in parent process" + ); + + const toolbox = await openToolboxForTab(tab); + + info("Navigate to a URL supporting remote process"); + await navigateTo(URL_2); + + is( + tab.linkedBrowser.getAttribute("remote"), + "true", + "Navigated to a data: URI and switching to remote" + ); + + info("Veryify we are inspecting the new document"); + const console = await toolbox.selectTool("webconsole"); + const { ui } = console.hud; + ui.wrapper.dispatchEvaluateExpression("document.location.href"); + await waitUntil(() => ui.outputNode.querySelector(".result")); + const url = ui.outputNode.querySelector(".result"); + + ok( + url.textContent.includes(URL_2), + "The console inspects the second document" + ); + + const { client } = toolbox.target; + await toolbox.destroy(); + ok(client._transportClosed, "The client is closed after closing the toolbox"); +}); diff --git a/devtools/client/framework/test/browser_toolbox_screenshot_tool.js b/devtools/client/framework/test/browser_toolbox_screenshot_tool.js new file mode 100644 index 0000000000..63c8b9fd58 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_screenshot_tool.js @@ -0,0 +1,126 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const exampleOrgDocument = `https://example.org/document-builder.sjs`; +const exampleComDocument = `https://example.com/document-builder.sjs`; + +const TEST_URL = `${exampleOrgDocument}?html= + <style> + body { + margin: 0; + height: 10001px; + } + iframe { + height: 50px; + border:none; + display: block; + } + </style> + <iframe + src="${exampleOrgDocument}?html=<body style='margin:0;height:30px;background:rgb(255,0,0)'></body>" + id="same-origin"></iframe> + <iframe + src="${exampleComDocument}?html=<body style='margin:0;height:30px;background:rgb(0,255,0)'></body>" + id="remote"></iframe>`; + +add_task(async function () { + await pushPref("devtools.command-button-screenshot.enabled", true); + + await addTab(TEST_URL); + + info("Open the toolbox"); + const toolbox = await gDevTools.showToolboxForTab(gBrowser.selectedTab); + + const onScreenshotDownloaded = waitUntilScreenshot(); + toolbox.doc.querySelector("#command-button-screenshot").click(); + const filePath = await onScreenshotDownloaded; + + ok(!!filePath, "The screenshot was taken"); + + info("Create an image using the downloaded file as source"); + const image = new Image(); + const onImageLoad = once(image, "load"); + image.src = PathUtils.toFileURI(filePath); + await onImageLoad; + + const dpr = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => content.wrappedJSObject.devicePixelRatio + ); + + info("Check that the same-origin iframe is rendered in the screenshot"); + await checkImageColorAt({ + image, + y: 10 * dpr, + expectedColor: `rgb(255, 0, 0)`, + label: "The same-origin iframe is rendered properly in the screenshot", + }); + + info("Check that the remote iframe is rendered in the screenshot"); + await checkImageColorAt({ + image, + y: 60 * dpr, + expectedColor: `rgb(0, 255, 0)`, + label: "The remote iframe is rendered properly in the screenshot", + }); + + info( + "Check that a warning message was displayed to indicate the screenshot was truncated" + ); + const notificationBox = await waitFor(() => + toolbox.doc.querySelector(".notificationbox") + ); + + const message = notificationBox.querySelector(".notification").textContent; + ok( + message.startsWith("The image was cut off"), + `The warning message is rendered as expected (${message})` + ); + + // Remove the downloaded screenshot file + await IOUtils.remove(filePath); + + info( + "Check that taking a screenshot in a private window doesn't appear in the non-private window" + ); + const privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + ok(PrivateBrowsingUtils.isWindowPrivate(privateWindow), "window is private"); + const privateBrowser = privateWindow.gBrowser; + privateBrowser.selectedTab = BrowserTestUtils.addTab( + privateBrowser, + TEST_URL + ); + + info("private tab opened"); + ok( + PrivateBrowsingUtils.isBrowserPrivate(privateBrowser.selectedBrowser), + "tab window is private" + ); + + const privateToolbox = await gDevTools.showToolboxForTab( + privateBrowser.selectedTab + ); + + const onPrivateScreenshotDownloaded = waitUntilScreenshot({ + isWindowPrivate: true, + }); + privateToolbox.doc.querySelector("#command-button-screenshot").click(); + const privateScreenshotFilePath = await onPrivateScreenshotDownloaded; + ok( + !!privateScreenshotFilePath, + "The screenshot was taken in the private window" + ); + + // Remove the downloaded screenshot file + await IOUtils.remove(privateScreenshotFilePath); + + // cleanup the downloads + await resetDownloads(); + + const closePromise = BrowserTestUtils.windowClosed(privateWindow); + privateWindow.BrowserTryToCloseWindow(); + await closePromise; +}); diff --git a/devtools/client/framework/test/browser_toolbox_select_event.js b/devtools/client/framework/test/browser_toolbox_select_event.js new file mode 100644 index 0000000000..ebdae9af13 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_select_event.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PAGE_URL = "data:text/html;charset=utf-8,test select events"; + +requestLongerTimeout(2); + +add_task(async function () { + const tab = await addTab(PAGE_URL); + + let toolbox = await openToolboxForTab(tab, "webconsole", "bottom"); + await testSelectEvent("inspector"); + await testSelectEvent("webconsole"); + await testSelectEvent("styleeditor"); + await testSelectEvent("inspector"); + await testSelectEvent("webconsole"); + await testSelectEvent("styleeditor"); + + await testToolSelectEvent("inspector"); + await testToolSelectEvent("webconsole"); + await testToolSelectEvent("styleeditor"); + await toolbox.destroy(); + + toolbox = await openToolboxForTab(tab, "webconsole", "right"); + await testSelectEvent("inspector"); + await testSelectEvent("webconsole"); + await testSelectEvent("styleeditor"); + await testSelectEvent("inspector"); + await testSelectEvent("webconsole"); + await testSelectEvent("styleeditor"); + await toolbox.destroy(); + + toolbox = await openToolboxForTab(tab, "webconsole", "window"); + await testSelectEvent("inspector"); + await testSelectEvent("webconsole"); + await testSelectEvent("styleeditor"); + await testSelectEvent("inspector"); + await testSelectEvent("webconsole"); + await testSelectEvent("styleeditor"); + await toolbox.destroy(); + + await testSelectToolRace(); + + /** + * Assert that selecting the given toolId raises a select event + * @param {toolId} Id of the tool to test + */ + async function testSelectEvent(toolId) { + const onSelect = toolbox.once("select"); + toolbox.selectTool(toolId); + const id = await onSelect; + is(id, toolId, toolId + " selected"); + } + + /** + * Assert that selecting the given toolId raises its corresponding + * selected event + * @param {toolId} Id of the tool to test + */ + async function testToolSelectEvent(toolId) { + const onSelected = toolbox.once(toolId + "-selected"); + toolbox.selectTool(toolId); + await onSelected; + is(toolbox.currentToolId, toolId, toolId + " tool selected"); + } + + /** + * Assert that two calls to selectTool won't race + */ + async function testSelectToolRace() { + const toolbox = await openToolboxForTab(tab, "webconsole"); + let selected = false; + const onSelect = (event, id) => { + if (selected) { + ok(false, "Got more than one 'select' event"); + } else { + selected = true; + } + }; + toolbox.once("select", onSelect); + const p1 = toolbox.selectTool("inspector"); + const p2 = toolbox.selectTool("inspector"); + // Check that both promises don't resolve too early + const checkSelectToolResolution = panel => { + ok(selected, "selectTool resolves only after 'select' event is fired"); + const inspector = toolbox.getPanel("inspector"); + is(panel, inspector, "selecTool resolves to the panel instance"); + }; + p1.then(checkSelectToolResolution); + p2.then(checkSelectToolResolution); + await p1; + await p2; + + await toolbox.destroy(); + } +}); diff --git a/devtools/client/framework/test/browser_toolbox_selected_tool_unavailable.js b/devtools/client/framework/test/browser_toolbox_selected_tool_unavailable.js new file mode 100644 index 0000000000..c55ad5867c --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_selected_tool_unavailable.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that opening the toolbox doesn't throw when the previously selected +// tool is not supported. + +const testToolDefinition = { + id: "testTool", + isToolSupported: () => true, + visibilityswitch: "devtools.test-tool.enabled", + url: "about:blank", + label: "someLabel", + build: (iframeWindow, toolbox) => { + return { + target: toolbox.target, + toolbox, + isReady: true, + destroy: () => {}, + panelDoc: iframeWindow.document, + }; + }, +}; + +add_task(async function () { + gDevTools.registerTool(testToolDefinition); + let tab = await addTab("about:blank"); + + let toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: testToolDefinition.id, + }); + is(toolbox.currentToolId, "testTool", "test-tool was selected"); + await toolbox.destroy(); + + // Make the previously selected tool unavailable. + testToolDefinition.isToolSupported = () => false; + + toolbox = await gDevTools.showToolboxForTab(tab); + is(toolbox.currentToolId, "webconsole", "web console was selected"); + + await toolbox.destroy(); + gDevTools.unregisterTool(testToolDefinition.id); + tab = toolbox = null; + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_selectionchanged_event.js b/devtools/client/framework/test/browser_toolbox_selectionchanged_event.js new file mode 100644 index 0000000000..e4e0dcc446 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_selectionchanged_event.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PAGE_URL = "data:text/html;charset=utf-8,<body><div></div></body>"; + +add_task(async function () { + const tab = await addTab(PAGE_URL); + const toolbox = await openToolboxForTab(tab, "inspector", "bottom"); + const inspector = toolbox.getCurrentPanel(); + + const root = await inspector.walker.getRootNode(); + const body = await inspector.walker.querySelector(root, "body"); + const node = await inspector.walker.querySelector(root, "div"); + + is(inspector.selection.nodeFront, body, "Body is selected by default"); + + // Listen to selection changed + const onSelectionChanged = toolbox.once("selection-changed"); + + info("Select the div and wait for the selection-changed event to be fired."); + inspector.selection.setNodeFront(node, { reason: "browser-context-menu" }); + + await onSelectionChanged; + + is(inspector.selection.nodeFront, node, "Div is now selected"); + + // Listen to cleared selection changed + const onClearSelectionChanged = toolbox.once("selection-changed"); + + info( + "Clear the selection and wait for the selection-changed event to be fired." + ); + inspector.selection.setNodeFront(null); + + await onClearSelectionChanged; + + is(inspector.selection.nodeFront, null, "The selection is null as expected"); +}); diff --git a/devtools/client/framework/test/browser_toolbox_show_toolbox_tool_ready.js b/devtools/client/framework/test/browser_toolbox_show_toolbox_tool_ready.js new file mode 100644 index 0000000000..d24f8cfedf --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_show_toolbox_tool_ready.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = + "data:text/html;charset=utf8,test for showToolbox called while tool is opened"; +const lazyToolId = "testtool1"; + +registerCleanupFunction(() => { + gDevTools.unregisterTool(lazyToolId); +}); + +// Delay to wait before the lazy tool should finish +const TOOL_OPEN_DELAY = 3000; + +class LazyDevToolsPanel extends DevToolPanel { + constructor(iframeWindow, toolbox) { + super(iframeWindow, toolbox); + } + + async open() { + await wait(TOOL_OPEN_DELAY); + return this; + } +} + +function isPanelReady(toolbox, toolId) { + return !!toolbox.getPanel(toolId); +} + +/** + * Test that showToolbox will wait until the specified tool is completely read before + * returning. See Bug 1543907. + */ +add_task(async function automaticallyBindTexbox() { + info( + "Registering a tool with an input field and making sure the context menu works" + ); + + gDevTools.registerTool({ + id: lazyToolId, + isToolSupported: () => true, + url: CHROME_URL_ROOT + "doc_lazy_tool.html", + label: "Lazy", + build(iframeWindow, toolbox) { + this.panel = new LazyDevToolsPanel(iframeWindow, toolbox); + return this.panel.open(); + }, + }); + + const tab = await addTab(URL); + const toolbox = await openToolboxForTab(tab, "inspector"); + const onLazyToolReady = toolbox.once(lazyToolId + "-ready"); + toolbox.selectTool(lazyToolId); + + info("Wait until toolbox considers the current tool is the lazy tool"); + await waitUntil(() => toolbox.currentToolId == lazyToolId); + + ok(!isPanelReady(toolbox, lazyToolId), "lazyTool should not be ready yet"); + await gDevTools.showToolboxForTab(tab, { toolId: lazyToolId }); + ok( + isPanelReady(toolbox, lazyToolId), + "lazyTool should not ready after showToolbox" + ); + + // Make sure lazyTool is ready before leaving the test. + await onLazyToolReady; +}); diff --git a/devtools/client/framework/test/browser_toolbox_split_console.js b/devtools/client/framework/test/browser_toolbox_split_console.js new file mode 100644 index 0000000000..e69a493df9 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_split_console.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that these toolbox split console APIs work: +// * toolbox.useKeyWithSplitConsole() +// * toolbox.isSplitConsoleFocused + +let gToolbox = null; +let panelWin = null; + +const URL = "data:text/html;charset=utf8,test split console key delegation"; + +add_task(async function () { + const tab = await addTab(URL); + gToolbox = await gDevTools.showToolboxForTab(tab, { toolId: "jsdebugger" }); + panelWin = gToolbox.getPanel("jsdebugger").panelWin; + + await gToolbox.openSplitConsole(); + await testIsSplitConsoleFocused(); + await testUseKeyWithSplitConsole(); + await testUseKeyWithSplitConsoleWrongTool(); + + await cleanup(); +}); + +async function testIsSplitConsoleFocused() { + await gToolbox.openSplitConsole(); + // The newly opened split console should have focus + ok(gToolbox.isSplitConsoleFocused(), "Split console is focused"); + panelWin.focus(); + ok(!gToolbox.isSplitConsoleFocused(), "Split console is no longer focused"); +} + +// A key bound to the selected tool should trigger it's command +function testUseKeyWithSplitConsole() { + let commandCalled = false; + + info("useKeyWithSplitConsole on debugger while debugger is focused"); + gToolbox.useKeyWithSplitConsole( + "F3", + () => { + commandCalled = true; + }, + "jsdebugger" + ); + + info("synthesizeKey with the console focused"); + focusConsoleInput(); + synthesizeKeyShortcut("F3", panelWin); + + ok(commandCalled, "Shortcut key should trigger the command"); +} + +// A key bound to a *different* tool should not trigger it's command +function testUseKeyWithSplitConsoleWrongTool() { + let commandCalled = false; + + info("useKeyWithSplitConsole on inspector while debugger is focused"); + gToolbox.useKeyWithSplitConsole( + "F4", + () => { + commandCalled = true; + }, + "inspector" + ); + + info("synthesizeKey with the console focused"); + focusConsoleInput(); + synthesizeKeyShortcut("F4", panelWin); + + ok(!commandCalled, "Shortcut key shouldn't trigger the command"); +} + +async function cleanup() { + await gToolbox.destroy(); + gBrowser.removeCurrentTab(); + gToolbox = panelWin = null; +} + +function focusConsoleInput() { + gToolbox.getPanel("webconsole").hud.jsterm.focus(); +} diff --git a/devtools/client/framework/test/browser_toolbox_tabsswitch_shortcuts.js b/devtools/client/framework/test/browser_toolbox_tabsswitch_shortcuts.js new file mode 100644 index 0000000000..06123e8d68 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_tabsswitch_shortcuts.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +add_task(async function () { + const tab = await addTab("about:blank"); + + const toolIDs = (await getSupportedToolIds(tab)).filter( + id => id != "options" + ); + const toolbox = await gDevTools.showToolboxForTab(tab, { + hostType: Toolbox.HostType.BOTTOM, + toolId: toolIDs[0], + }); + const nextShortcut = L10N.getStr("toolbox.nextTool.key"); + const prevShortcut = L10N.getStr("toolbox.previousTool.key"); + + // Iterate over all tools, starting from options to netmonitor, in normal + // order. + for (let i = 1; i < toolIDs.length; i++) { + await testShortcuts(toolbox, i, nextShortcut, toolIDs); + } + + // Iterate again, in the same order, starting from netmonitor (so next one is + // 0: options). + for (let i = 0; i < toolIDs.length; i++) { + await testShortcuts(toolbox, i, nextShortcut, toolIDs); + } + + // Iterate over all tools in reverse order, starting from netmonitor to + // options. + for (let i = toolIDs.length - 2; i >= 0; i--) { + await testShortcuts(toolbox, i, prevShortcut, toolIDs); + } + + // Iterate again, in reverse order again, starting from options (so next one + // is length-1: netmonitor). + for (let i = toolIDs.length - 1; i >= 0; i--) { + await testShortcuts(toolbox, i, prevShortcut, toolIDs); + } + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function testShortcuts(toolbox, index, shortcut, toolIDs) { + info( + "Testing shortcut to switch to tool " + + index + + ":" + + toolIDs[index] + + " using shortcut " + + shortcut + ); + + const onToolSelected = toolbox.once("select"); + synthesizeKeyShortcut(shortcut); + const id = await onToolSelected; + + info("toolbox-select event from " + id); + + is( + toolIDs.indexOf(id), + index, + "Correct tool is selected on pressing the shortcut for " + id + ); +} diff --git a/devtools/client/framework/test/browser_toolbox_telemetry_activate_splitconsole.js b/devtools/client/framework/test/browser_toolbox_telemetry_activate_splitconsole.js new file mode 100644 index 0000000000..cd1a478f5c --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_telemetry_activate_splitconsole.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = + "data:text/html;charset=utf8,browser_toolbox_telemetry_activate_splitconsole.js"; +const ALL_CHANNELS = Ci.nsITelemetry.DATASET_ALL_CHANNELS; +const DATA = [ + { + timestamp: null, + category: "devtools.main", + method: "activate", + object: "split_console", + value: null, + extra: { + host: "bottom", + width: "1300", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "deactivate", + object: "split_console", + value: null, + extra: { + host: "bottom", + width: "1300", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "activate", + object: "split_console", + value: null, + extra: { + host: "bottom", + width: "1300", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "deactivate", + object: "split_console", + value: null, + extra: { + host: "bottom", + width: "1300", + }, + }, +]; + +add_task(async function () { + // See Bug 1500141: this test frequently fails on beta because some highlighter + // requests made by the BoxModel component in the layout view come back when the + // connection between the client and the server has been destroyed. We are forcing + // the computed view here to avoid the failures but ideally we should have an event + // or a promise on the inspector we can wait for to be sure the initialization is over. + // Logged Bug 1500918 to investigate this. + await pushPref("devtools.inspector.activeSidebar", "computedview"); + + // Let's reset the counts. + Services.telemetry.clearEvents(); + + // Ensure no events have been logged + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + ok(!snapshot.parent, "No events have been logged for the main process"); + + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "inspector", + }); + + await toolbox.openSplitConsole(); + await toolbox.closeSplitConsole(); + await toolbox.openSplitConsole(); + await toolbox.closeSplitConsole(); + + await checkResults(); +}); + +async function checkResults() { + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + const events = snapshot.parent.filter( + event => + event[1] === "devtools.main" && + (event[2] === "activate" || event[2] === "deactivate") + ); + + for (const i in DATA) { + const [timestamp, category, method, object, value, extra] = events[i]; + const expected = DATA[i]; + + // ignore timestamp + Assert.greater(timestamp, 0, "timestamp is greater than 0"); + is(category, expected.category, "category is correct"); + is(method, expected.method, "method is correct"); + is(object, expected.object, "object is correct"); + is(value, expected.value, "value is correct"); + + // extras + is(extra.host, expected.extra.host, "host is correct"); + ok(extra.width > 0, "width is greater than 0"); + } +} diff --git a/devtools/client/framework/test/browser_toolbox_telemetry_close.js b/devtools/client/framework/test/browser_toolbox_telemetry_close.js new file mode 100644 index 0000000000..47aa1c056b --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_telemetry_close.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const URL = "data:text/html;charset=utf8,browser_toolbox_telemetry_close.js"; +const { RIGHT, BOTTOM } = Toolbox.HostType; +const DATA = [ + { + category: "devtools.main", + method: "close", + object: "tools", + value: null, + extra: { + host: "right", + width: w => w > 0, + }, + }, + { + category: "devtools.main", + method: "close", + object: "tools", + value: null, + extra: { + host: "bottom", + width: w => w > 0, + }, + }, +]; + +add_task(async function () { + // Let's reset the counts. + Services.telemetry.clearEvents(); + + // Ensure no events have been logged + TelemetryTestUtils.assertNumberOfEvents(0); + + await openAndCloseToolbox("webconsole", RIGHT); + await openAndCloseToolbox("webconsole", BOTTOM); + + checkResults(); +}); + +async function openAndCloseToolbox(toolId, host) { + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab, { toolId }); + + await toolbox.switchHost(host); + await toolbox.destroy(); +} + +function checkResults() { + TelemetryTestUtils.assertEvents(DATA, { + category: "devtools.main", + method: "close", + object: "tools", + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_telemetry_enter.js b/devtools/client/framework/test/browser_toolbox_telemetry_enter.js new file mode 100644 index 0000000000..4cb4611a97 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_telemetry_enter.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = "data:text/html;charset=utf8,browser_toolbox_telemetry_enter.js"; +const ALL_CHANNELS = Ci.nsITelemetry.DATASET_ALL_CHANNELS; +const DATA = [ + { + timestamp: null, + category: "devtools.main", + method: "enter", + object: "inspector", + value: null, + extra: { + host: "bottom", + width: "1300", + start_state: "initial_panel", + panel_name: "inspector", + cold: "true", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "enter", + object: "jsdebugger", + value: null, + extra: { + host: "bottom", + width: "1300", + start_state: "toolbox_show", + panel_name: "jsdebugger", + cold: "true", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "enter", + object: "styleeditor", + value: null, + extra: { + host: "bottom", + width: "1300", + start_state: "toolbox_show", + panel_name: "styleeditor", + cold: "true", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "enter", + object: "netmonitor", + value: null, + extra: { + host: "bottom", + width: "1300", + start_state: "toolbox_show", + panel_name: "netmonitor", + cold: "true", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "enter", + object: "storage", + value: null, + extra: { + host: "bottom", + width: "1300", + start_state: "toolbox_show", + panel_name: "storage", + cold: "true", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "enter", + object: "netmonitor", + value: null, + extra: { + host: "bottom", + width: "1300", + start_state: "toolbox_show", + panel_name: "netmonitor", + cold: "false", + }, + }, +]; + +add_task(async function () { + // Let's reset the counts. + Services.telemetry.clearEvents(); + + // Ensure no events have been logged + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + ok(!snapshot.parent, "No events have been logged for the main process"); + + const tab = await addTab(URL); + + // Set up some cached messages for the web console. + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.console.log("test 1"); + content.console.log("test 2"); + content.console.log("test 3"); + content.console.log("test 4"); + content.console.log("test 5"); + }); + + // Open the toolbox + await gDevTools.showToolboxForTab(tab, { toolId: "inspector" }); + + // Switch between a few tools + await gDevTools.showToolboxForTab(tab, { toolId: "jsdebugger" }); + await gDevTools.showToolboxForTab(tab, { toolId: "styleeditor" }); + await gDevTools.showToolboxForTab(tab, { toolId: "netmonitor" }); + await gDevTools.showToolboxForTab(tab, { toolId: "storage" }); + await gDevTools.showToolboxForTab(tab, { toolId: "netmonitor" }); + + await checkResults(); +}); + +async function checkResults() { + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + const events = snapshot.parent.filter( + event => + event[1] === "devtools.main" && event[2] === "enter" && event[4] === null + ); + + for (const i in DATA) { + const [timestamp, category, method, object, value, extra] = events[i]; + const expected = DATA[i]; + + // ignore timestamp + Assert.greater(timestamp, 0, "timestamp is greater than 0"); + is(category, expected.category, "category is correct"); + is(method, expected.method, "method is correct"); + is(object, expected.object, "object is correct"); + is(value, expected.value, "value is correct"); + + // extras + is(extra.host, expected.extra.host, "host is correct"); + ok(extra.width > 0, "width is greater than 0"); + is(extra.start_state, expected.extra.start_state, "start_state is correct"); + is(extra.panel_name, expected.extra.panel_name, "panel_name is correct"); + is(extra.cold, expected.extra.cold, "cold is correct"); + } +} diff --git a/devtools/client/framework/test/browser_toolbox_telemetry_exit.js b/devtools/client/framework/test/browser_toolbox_telemetry_exit.js new file mode 100644 index 0000000000..3056b9af8c --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_telemetry_exit.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = "data:text/html;charset=utf8,browser_toolbox_telemetry_enter.js"; +const ALL_CHANNELS = Ci.nsITelemetry.DATASET_ALL_CHANNELS; +const DATA = [ + { + timestamp: null, + category: "devtools.main", + method: "exit", + object: "inspector", + value: null, + extra: { + host: "bottom", + width: 1300, + panel_name: "inspector", + next_panel: "jsdebugger", + reason: "toolbox_show", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "exit", + object: "jsdebugger", + value: null, + extra: { + host: "bottom", + width: 1300, + panel_name: "jsdebugger", + next_panel: "styleeditor", + reason: "toolbox_show", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "exit", + object: "styleeditor", + value: null, + extra: { + host: "bottom", + width: 1300, + panel_name: "styleeditor", + next_panel: "netmonitor", + reason: "toolbox_show", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "exit", + object: "netmonitor", + value: null, + extra: { + host: "bottom", + width: 1300, + panel_name: "netmonitor", + next_panel: "storage", + reason: "toolbox_show", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "exit", + object: "storage", + value: null, + extra: { + host: "bottom", + width: 1300, + panel_name: "storage", + next_panel: "netmonitor", + reason: "toolbox_show", + }, + }, +]; + +add_task(async function () { + // Let's reset the counts. + Services.telemetry.clearEvents(); + + // Ensure no events have been logged + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + ok(!snapshot.parent, "No events have been logged for the main process"); + + const tab = await addTab(URL); + + // Open the toolbox + await gDevTools.showToolboxForTab(tab, { toolId: "inspector" }); + + // Switch between a few tools + await gDevTools.showToolboxForTab(tab, { toolId: "jsdebugger" }); + await gDevTools.showToolboxForTab(tab, { toolId: "styleeditor" }); + await gDevTools.showToolboxForTab(tab, { toolId: "netmonitor" }); + await gDevTools.showToolboxForTab(tab, { toolId: "storage" }); + await gDevTools.showToolboxForTab(tab, { toolId: "netmonitor" }); + + await checkResults(); +}); + +async function checkResults() { + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + const events = snapshot.parent.filter( + event => + event[1] === "devtools.main" && event[2] === "exit" && event[4] === null + ); + + for (const i in DATA) { + const [timestamp, category, method, object, value, extra] = events[i]; + const expected = DATA[i]; + + // ignore timestamp + Assert.greater(timestamp, 0, "timestamp is greater than 0"); + is(category, expected.category, "category is correct"); + is(method, expected.method, "method is correct"); + is(object, expected.object, "object is correct"); + is(value, expected.value, "value is correct"); + + // extras + is(extra.host, expected.extra.host, "host is correct"); + ok(extra.width > 0, "width is greater than 0"); + is(extra.panel_name, expected.extra.panel_name, "panel_name is correct"); + is(extra.next_panel, expected.extra.next_panel, "next_panel is correct"); + is(extra.reason, expected.extra.reason, "reason is correct"); + } +} diff --git a/devtools/client/framework/test/browser_toolbox_telemetry_open_event.js b/devtools/client/framework/test/browser_toolbox_telemetry_open_event.js new file mode 100644 index 0000000000..aa2f7ea2ed --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_telemetry_open_event.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the "open" telemetry event is correctly logged when opening the +// toolbox. +const ALL_CHANNELS = Ci.nsITelemetry.DATASET_ALL_CHANNELS; + +add_task(async function () { + Services.prefs.clearUserPref("devtools.toolbox.selectedTool"); + const tab = await addTab("data:text/html;charset=utf-8,Test open event"); + + info("Open the toolbox with a shortcut to trigger the open event"); + const onToolboxReady = gDevTools.once("toolbox-ready"); + EventUtils.synthesizeKey("VK_F12", {}); + await onToolboxReady; + + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + // The telemetry is sent by DevToolsStartup and so isn't flaged against any session id + const events = snapshot.parent.filter( + event => + event[1] === "devtools.main" && + event[2] === "open" && + event[5].session_id == -1 + ); + + is(events.length, 1, "Telemetry open event was logged"); + + const extras = events[0][5]; + is(extras.entrypoint, "KeyShortcut", "entrypoint extra is correct"); + // The logged shortcut is `${modifiers}+${shortcut}`, which adds an + // extra `+` before F12 here. + // See https://searchfox.org/mozilla-central/rev/c7e8bc4996f979e5876b33afae3de3b1ab4f3ae1/devtools/startup/DevToolsStartup.jsm#1070 + is(extras.shortcut, "+F12", "entrypoint shortcut is correct"); + + gBrowser.removeTab(tab); +}); diff --git a/devtools/client/framework/test/browser_toolbox_textbox_context_menu.js b/devtools/client/framework/test/browser_toolbox_textbox_context_menu.js new file mode 100644 index 0000000000..903d0c9912 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_textbox_context_menu.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// HTML inputs don't automatically get the 'edit' context menu, so we have +// a helper on the toolbox to do so. Make sure that shows menu items in the +// right state, and that it works for an input inside of a panel. + +const URL = "data:text/html;charset=utf8,test for textbox context menu"; +const textboxToolId = "testtool1"; + +registerCleanupFunction(() => { + gDevTools.unregisterTool(textboxToolId); +}); + +add_task(async function checkMenuEntryStates() { + info("Checking the state of edit menuitems with an empty clipboard"); + const toolbox = await openNewTabAndToolbox(URL, "inspector"); + + emptyClipboard(); + + // Make sure the focus is predictable. + const inspector = toolbox.getPanel("inspector"); + const onFocus = once(inspector.searchBox, "focus"); + inspector.searchBox.focus(); + await onFocus; + + info("Opening context menu"); + const onContextMenuPopup = toolbox.once("menu-open"); + synthesizeContextMenuEvent(inspector.searchBox); + await onContextMenuPopup; + + const textboxContextMenu = toolbox.getTextBoxContextMenu(); + ok(textboxContextMenu, "The textbox context menu is loaded in the toolbox"); + + const cmdUndo = textboxContextMenu.querySelector("#editmenu-undo"); + const cmdDelete = textboxContextMenu.querySelector("#editmenu-delete"); + const cmdSelectAll = textboxContextMenu.querySelector("#editmenu-selectAll"); + const cmdCut = textboxContextMenu.querySelector("#editmenu-cut"); + const cmdCopy = textboxContextMenu.querySelector("#editmenu-copy"); + const cmdPaste = textboxContextMenu.querySelector("#editmenu-paste"); + + is(cmdUndo.getAttribute("disabled"), "true", "cmdUndo is disabled"); + is(cmdDelete.getAttribute("disabled"), "true", "cmdDelete is disabled"); + is(cmdSelectAll.getAttribute("disabled"), "true", "cmdSelectAll is disabled"); + is(cmdCut.getAttribute("disabled"), "true", "cmdCut is disabled"); + is(cmdCopy.getAttribute("disabled"), "true", "cmdCopy is disabled"); + + if (isWindows()) { + // emptyClipboard only works on Windows (666254), assert paste only for this OS. + is(cmdPaste.getAttribute("disabled"), "true", "cmdPaste is disabled"); + } + + const onContextMenuHidden = toolbox.once("menu-close"); + if (Services.prefs.getBoolPref("widget.macos.native-context-menus", false)) { + info("Using hidePopup semantics because of macOS native context menus."); + textboxContextMenu.hidePopup(); + } else { + EventUtils.sendKey("ESCAPE", toolbox.win); + } + await onContextMenuHidden; +}); + +add_task(async function automaticallyBindTexbox() { + info( + "Registering a tool with an input field and making sure the context menu works" + ); + gDevTools.registerTool({ + id: textboxToolId, + isToolSupported: () => true, + url: CHROME_URL_ROOT + "doc_textbox_tool.html", + label: "Context menu works without tool intervention", + build(iframeWindow, toolbox) { + this.panel = createTestPanel(iframeWindow, toolbox); + return this.panel.open(); + }, + }); + + const toolbox = await openNewTabAndToolbox(URL, textboxToolId); + is(toolbox.currentToolId, textboxToolId, "The custom tool has been opened"); + + const doc = toolbox.getCurrentPanel().document; + await checkTextBox(doc.querySelector("input[type=text]"), toolbox); + await checkTextBox(doc.querySelector("textarea"), toolbox); + await checkTextBox(doc.querySelector("input[type=search]"), toolbox); + await checkTextBox(doc.querySelector("input:not([type])"), toolbox); + await checkNonTextInput(doc.querySelector("input[type=radio]"), toolbox); +}); + +async function checkNonTextInput(input, toolbox) { + let textboxContextMenu = toolbox.getTextBoxContextMenu(); + ok(!textboxContextMenu, "The menu is closed"); + + info( + "Simulating context click on the non text input and expecting no menu to open" + ); + const eventBubbledUp = new Promise(resolve => { + input.ownerDocument.addEventListener("contextmenu", resolve, { + once: true, + }); + }); + synthesizeContextMenuEvent(input); + info("Waiting for event"); + await eventBubbledUp; + + textboxContextMenu = toolbox.getTextBoxContextMenu(); + ok(!textboxContextMenu, "The menu is still closed"); +} + +async function checkTextBox(textBox, toolbox) { + let textboxContextMenu = toolbox.getTextBoxContextMenu(); + ok(!textboxContextMenu, "The menu is closed"); + + info( + "Simulating context click on the textbox and expecting the menu to open" + ); + const onContextMenu = toolbox.once("menu-open"); + synthesizeContextMenuEvent(textBox); + await onContextMenu; + + textboxContextMenu = toolbox.getTextBoxContextMenu(); + ok(textboxContextMenu, "The menu is now visible"); + + info("Closing the menu"); + const onContextMenuHidden = toolbox.once("menu-close"); + if (Services.prefs.getBoolPref("widget.macos.native-context-menus", false)) { + info("Using hidePopup semantics because of macOS native context menus."); + textboxContextMenu.hidePopup(); + } else { + EventUtils.sendKey("ESCAPE", toolbox.win); + } + await onContextMenuHidden; + + textboxContextMenu = toolbox.getTextBoxContextMenu(); + ok(!textboxContextMenu, "The menu is closed again"); +} diff --git a/devtools/client/framework/test/browser_toolbox_theme.js b/devtools/client/framework/test/browser_toolbox_theme.js new file mode 100644 index 0000000000..63d83e8312 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_theme.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const PREF_DEVTOOLS_THEME = "devtools.theme"; + +registerCleanupFunction(() => { + // Set preferences back to their original values + Services.prefs.clearUserPref(PREF_DEVTOOLS_THEME); +}); + +add_task(async function testDevtoolsTheme() { + info("Checking stylesheet and :root attributes based on devtools theme."); + Services.prefs.setCharPref(PREF_DEVTOOLS_THEME, "light"); + is( + document.getElementById("appcontent").getAttribute("devtoolstheme"), + "light", + "The element has an attribute based on devtools theme." + ); + + Services.prefs.setCharPref(PREF_DEVTOOLS_THEME, "dark"); + is( + document.getElementById("appcontent").getAttribute("devtoolstheme"), + "dark", + "The element has an attribute based on devtools theme." + ); + + Services.prefs.setCharPref(PREF_DEVTOOLS_THEME, "unknown"); + is( + document.getElementById("appcontent").getAttribute("devtoolstheme"), + "light", + "The element has 'light' as a default for the devtoolstheme attribute." + ); +}); diff --git a/devtools/client/framework/test/browser_toolbox_theme_registration.js b/devtools/client/framework/test/browser_toolbox_theme_registration.js new file mode 100644 index 0000000000..6f5d2bc679 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_theme_registration.js @@ -0,0 +1,166 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for dynamically registering and unregistering themes +const CHROME_URL = + "chrome://mochitests/content/browser/devtools/client/framework/test/"; +const TEST_THEME_NAME = "test-theme"; +const LIGHT_THEME_NAME = "light"; + +var toolbox; + +add_task(async function themeRegistration() { + const tab = await addTab("data:text/html,test"); + toolbox = await gDevTools.showToolboxForTab(tab, { toolId: "options" }); + + const themeId = await new Promise(resolve => { + gDevTools.once("theme-registered", registeredThemeId => { + resolve(registeredThemeId); + }); + + gDevTools.registerTheme({ + id: TEST_THEME_NAME, + label: "Test theme", + stylesheets: [CHROME_URL + "doc_theme.css"], + classList: ["theme-test"], + }); + }); + + is(themeId, TEST_THEME_NAME, "theme-registered event handler sent theme id"); + + ok(gDevTools.getThemeDefinitionMap().has(themeId), "theme added to map"); +}); + +add_task(async function themeInOptionsPanel() { + const panelWin = toolbox.getCurrentPanel().panelWin; + const doc = panelWin.frameElement.contentDocument; + const themeBox = doc.getElementById("devtools-theme-box"); + const testThemeOption = themeBox.querySelector( + `input[type=radio][value=${TEST_THEME_NAME}]` + ); + const eventsRecorded = []; + + function onThemeChanged(theme) { + eventsRecorded.push(theme); + } + gDevTools.on("theme-changed", onThemeChanged); + + ok(testThemeOption, "new theme exists in the Options panel"); + + const lightThemeOption = themeBox.querySelector( + `input[type=radio][value=${LIGHT_THEME_NAME}]` + ); + + let color = panelWin.getComputedStyle(themeBox).color; + isnot(color, "rgb(255, 0, 0)", "style unapplied"); + + let onThemeSwithComplete = once(panelWin, "theme-switch-complete"); + + // Select test theme. + testThemeOption.click(); + + info("Waiting for theme to finish loading"); + await onThemeSwithComplete; + + is( + gDevTools.getTheme(), + TEST_THEME_NAME, + "getTheme returns the expected theme" + ); + is( + eventsRecorded.pop(), + TEST_THEME_NAME, + "theme-changed fired with the expected theme" + ); + + color = panelWin.getComputedStyle(themeBox).color; + is(color, "rgb(255, 0, 0)", "style applied"); + + onThemeSwithComplete = once(panelWin, "theme-switch-complete"); + + // Select light theme + lightThemeOption.click(); + + info("Waiting for theme to finish loading"); + await onThemeSwithComplete; + + is( + gDevTools.getTheme(), + LIGHT_THEME_NAME, + "getTheme returns the expected theme" + ); + is( + eventsRecorded.pop(), + LIGHT_THEME_NAME, + "theme-changed fired with the expected theme" + ); + + color = panelWin.getComputedStyle(themeBox).color; + isnot(color, "rgb(255, 0, 0)", "style unapplied"); + + onThemeSwithComplete = once(panelWin, "theme-switch-complete"); + // Select test theme again. + testThemeOption.click(); + await onThemeSwithComplete; + is( + gDevTools.getTheme(), + TEST_THEME_NAME, + "getTheme returns the expected theme" + ); + is( + eventsRecorded.pop(), + TEST_THEME_NAME, + "theme-changed fired with the expected theme" + ); + + gDevTools.off("theme-changed", onThemeChanged); +}); + +add_task(async function themeUnregistration() { + const panelWin = toolbox.getCurrentPanel().panelWin; + const onUnRegisteredTheme = once(gDevTools, "theme-unregistered"); + const onThemeSwitchComplete = once(panelWin, "theme-switch-complete"); + const eventsRecorded = []; + + function onThemeChanged(theme) { + eventsRecorded.push(theme); + } + gDevTools.on("theme-changed", onThemeChanged); + + gDevTools.unregisterTheme(TEST_THEME_NAME); + await onUnRegisteredTheme; + await onThemeSwitchComplete; + + is( + gDevTools.getTheme(), + gDevTools.getAutoTheme(), + "getTheme returns the expected theme" + ); + is( + eventsRecorded.pop(), + gDevTools.getAutoTheme(), + "theme-changed fired with the expected theme" + ); + ok( + !gDevTools.getThemeDefinitionMap().has(TEST_THEME_NAME), + "theme removed from map" + ); + + const doc = panelWin.frameElement.contentDocument; + const themeBox = doc.getElementById("devtools-theme-box"); + + // The default theme must be selected now. + ok( + themeBox.querySelector(`#devtools-theme-box [value=auto]`).checked, + `auto theme must be selected` + ); + + gDevTools.off("theme-changed", onThemeChanged); +}); + +add_task(async function cleanup() { + await toolbox.destroy(); + toolbox = null; +}); diff --git a/devtools/client/framework/test/browser_toolbox_toggle.js b/devtools/client/framework/test/browser_toolbox_toggle.js new file mode 100644 index 0000000000..c3848ae5ad --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toggle.js @@ -0,0 +1,111 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the toolbox with ACCEL+SHIFT+I / ACCEL+ALT+I and F12 in docked +// and detached (window) modes. + +const URL = "data:text/html;charset=utf-8,Toggling devtools using shortcuts"; + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + // Make sure this test starts with the selectedTool pref cleared. Previous + // tests select various tools, and that sets this pref. + Services.prefs.clearUserPref("devtools.toolbox.selectedTool"); + + // Test with ACCEL+SHIFT+I / ACCEL+ALT+I (MacOSX) ; modifiers should match : + // - toolbox-key-toggle in devtools/client/framework/toolbox-window.xhtml + // - key_devToolboxMenuItem in browser/base/content/browser.xhtml + info("Test toggle using CTRL+SHIFT+I/CMD+ALT+I"); + await testToggle("I", { + accelKey: true, + shiftKey: !navigator.userAgent.match(/Mac/), + altKey: navigator.userAgent.match(/Mac/), + }); + + // Test with F12 ; no modifiers + info("Test toggle using F12"); + await testToggle("VK_F12", {}); +}); + +async function testToggle(key, modifiers) { + const tab = await addTab(URL + " ; key : '" + key + "'"); + await gDevTools.showToolboxForTab(tab); + + await testToggleDockedToolbox(tab, key, modifiers); + await testToggleDetachedToolbox(tab, key, modifiers); + + await cleanup(); +} + +async function testToggleDockedToolbox(tab, key, modifiers) { + const toolbox = gDevTools.getToolboxForTab(tab); + + isnot( + toolbox.hostType, + Toolbox.HostType.WINDOW, + "Toolbox is docked in the main window" + ); + + info("verify docked toolbox is destroyed when using toggle key"); + const onToolboxDestroyed = gDevTools.once("toolbox-destroyed"); + EventUtils.synthesizeKey(key, modifiers); + await onToolboxDestroyed; + ok(true, "Docked toolbox is destroyed when using a toggle key"); + + info("verify new toolbox is created when using toggle key"); + const onToolboxReady = gDevTools.once("toolbox-ready"); + EventUtils.synthesizeKey(key, modifiers); + await onToolboxReady; + ok(true, "Toolbox is created by using when toggle key"); +} + +async function testToggleDetachedToolbox(tab, key, modifiers) { + const toolbox = gDevTools.getToolboxForTab(tab); + + info("change the toolbox hostType to WINDOW"); + + await toolbox.switchHost(Toolbox.HostType.WINDOW); + is( + toolbox.hostType, + Toolbox.HostType.WINDOW, + "Toolbox opened on separate window" + ); + + info("Wait for focus on the toolbox window"); + await new Promise(res => waitForFocus(res, toolbox.win)); + + info("Focus main window to put the toolbox window in the background"); + + const onMainWindowFocus = once(window, "focus"); + window.focus(); + await onMainWindowFocus; + ok(true, "Main window focused"); + + info( + "Verify windowed toolbox is focused instead of closed when using " + + "toggle key from the main window" + ); + const toolboxWindow = toolbox.topWindow; + const onToolboxWindowFocus = once(toolboxWindow, "focus", true); + EventUtils.synthesizeKey(key, modifiers); + await onToolboxWindowFocus; + ok(true, "Toolbox focused and not destroyed"); + + info( + "Verify windowed toolbox is destroyed when using toggle key from its " + + "own window" + ); + + const onToolboxDestroyed = gDevTools.once("toolbox-destroyed"); + EventUtils.synthesizeKey(key, modifiers, toolboxWindow); + await onToolboxDestroyed; + ok(true, "Toolbox destroyed"); +} + +function cleanup() { + Services.prefs.setCharPref("devtools.toolbox.host", Toolbox.HostType.BOTTOM); + gBrowser.removeCurrentTab(); +} diff --git a/devtools/client/framework/test/browser_toolbox_tool_ready.js b/devtools/client/framework/test/browser_toolbox_tool_ready.js new file mode 100644 index 0000000000..306e4598af --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_tool_ready.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(5); + +async function performChecks(tab) { + let toolbox; + const toolIds = await getSupportedToolIds(tab); + for (const toolId of toolIds) { + info("About to open " + toolId); + toolbox = await gDevTools.showToolboxForTab(tab, { toolId }); + ok(toolbox, "toolbox exists for " + toolId); + is(toolbox.currentToolId, toolId, "currentToolId should be " + toolId); + + const panel = toolbox.getCurrentPanel(); + ok(panel, toolId + " panel has been registered in the toolbox"); + } + + await toolbox.destroy(); +} + +function test() { + (async function () { + toggleAllTools(true); + const tab = await addTab("about:blank"); + await performChecks(tab); + gBrowser.removeCurrentTab(); + toggleAllTools(false); + finish(); + })(); +} diff --git a/devtools/client/framework/test/browser_toolbox_tool_remote_reopen.js b/devtools/client/framework/test/browser_toolbox_tool_remote_reopen.js new file mode 100644 index 0000000000..52a6ea0655 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_tool_remote_reopen.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +// Bug 1277805: Too slow for debug runs +requestLongerTimeout(2); + +/** + * Bug 979536: Ensure fronts are destroyed after toolbox close. + * + * The fronts need to be destroyed manually to unbind their onPacket handlers. + * + * When you initialize a front and call |this.manage|, it adds a client actor + * pool that the DevToolsClient uses to route packet replies to that actor. + * + * Most (all?) tools create a new front when they are opened. When the destroy + * step is skipped and the tool is reopened, a second front is created and also + * added to the client actor pool. When a packet reply is received, is ends up + * being routed to the first (now unwanted) front that is still in the client + * actor pool. Since this is not the same front that was used to make the + * request, an error occurs. + * + * This problem does not occur with the toolbox for a local tab because the + * toolbox target creates its own DevToolsClient for the local tab, and the + * client is destroyed when the toolbox is closed, which removes the client + * actor pools, and avoids this issue. + * + * In remote debugging, we do not destroy the DevToolsClient on toolbox close + * because it can still used for other targets. + * Thus, the same client gets reused across multiple toolboxes, + * which leads to the tools failing if they don't destroy their fronts. + */ + +function runTools(tab) { + return (async function () { + let toolbox; + const toolIds = await getSupportedToolIds(tab); + for (const toolId of toolIds) { + info("About to open " + toolId); + toolbox = await gDevTools.showToolboxForTab(tab, { + toolId, + hostType: "window", + }); + ok(toolbox, "toolbox exists for " + toolId); + is(toolbox.currentToolId, toolId, "currentToolId should be " + toolId); + + const panel = toolbox.getCurrentPanel(); + ok(panel, toolId + " panel has been registered in the toolbox"); + } + + const client = toolbox.commands.client; + await toolbox.destroy(); + + // We need to check the client after the toolbox destruction. + return client; + })(); +} + +function test() { + (async function () { + toggleAllTools(true); + const tab = await addTab("about:blank"); + + const client = await runTools(tab); + + const rootFronts = [...client.mainRoot.fronts.values()]; + + // Actor fronts should be destroyed now that the toolbox has closed, but + // look for any that remain. + for (const pool of client.__pools) { + if (!pool.__poolMap) { + continue; + } + + // Ignore the root fronts, which are top-level pools and aren't released + // on toolbox destroy, but on client close. + if (rootFronts.includes(pool)) { + continue; + } + + for (const actor of pool.__poolMap.keys()) { + // Ignore the root front as it is only release on client close + if (actor == "root") { + continue; + } + ok(false, "Front for " + actor + " still held in pool!"); + } + } + + gBrowser.removeCurrentTab(); + DevToolsServer.destroy(); + toggleAllTools(false); + finish(); + })(); +} diff --git a/devtools/client/framework/test/browser_toolbox_toolbar_minimum_width.js b/devtools/client/framework/test/browser_toolbox_toolbar_minimum_width.js new file mode 100644 index 0000000000..cdd6678e6f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toolbar_minimum_width.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that all of buttons of tool tab go to the overflowed menu when the devtool's +// width is narrow. + +const SIDEBAR_WIDTH_PREF = "devtools.toolbox.sidebar.width"; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function (pickerEnable, commandsEnable) { + // 74px is Chevron(26px) + Meatball(24px) + Close(24px) + // devtools-browser.css defined this minimum width by using min-width. + Services.prefs.setIntPref(SIDEBAR_WIDTH_PREF, 74); + registerCleanupFunction(function () { + Services.prefs.clearUserPref(SIDEBAR_WIDTH_PREF); + }); + const tab = await addTab("about:blank"); + + info("Open devtools on the Inspector in a side dock"); + const toolbox = await openToolboxForTab( + tab, + "inspector", + Toolbox.HostType.RIGHT + ); + await waitUntil(() => toolbox.doc.querySelector(".tools-chevron-menu")); + + await openChevronMenu(toolbox); + + // Check that all of tools is overflowed. + toolbox.panelDefinitions.forEach(({ id }) => { + const menuItem = toolbox.doc.getElementById( + "tools-chevron-menupopup-" + id + ); + const tab = toolbox.doc.getElementById("toolbox-tab-" + id); + ok(menuItem, id + " is in the overflowed menu"); + ok(!tab, id + " tab does not exist"); + }); + + await closeChevronMenu(toolbox); +}); diff --git a/devtools/client/framework/test/browser_toolbox_toolbar_overflow.js b/devtools/client/framework/test/browser_toolbox_toolbar_overflow.js new file mode 100644 index 0000000000..9f964af18e --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toolbar_overflow.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that a button to access tools hidden by toolbar overflow is displayed when the +// toolbar starts to present an overflow. +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + const tab = await addTab("about:blank"); + + info("Open devtools on the Inspector in a bottom dock"); + const toolbox = await openToolboxForTab( + tab, + "inspector", + Toolbox.HostType.BOTTOM + ); + + const hostWindow = toolbox.topWindow; + const originalWidth = hostWindow.outerWidth; + const originalHeight = hostWindow.outerHeight; + + info( + "Resize devtools window to a width that should not trigger any overflow" + ); + let onResize = once(hostWindow, "resize"); + hostWindow.resizeTo(1350, 300); + await onResize; + + info("Wait for all buttons to be displayed"); + await waitUntil(() => { + return ( + toolbox.panelDefinitions.length === + toolbox.doc.querySelectorAll(".devtools-tab").length + ); + }); + + let chevronMenuButton = toolbox.doc.querySelector(".tools-chevron-menu"); + ok(!chevronMenuButton, "The chevron menu button is not displayed"); + + info("Resize devtools window to a width that should trigger an overflow"); + onResize = once(hostWindow, "resize"); + hostWindow.resizeTo(800, 300); + await onResize; + await waitUntil(() => !toolbox.doc.querySelector(".tools-chevron-menu")); + + info("Wait until the chevron menu button is available"); + await waitUntil(() => toolbox.doc.querySelector(".tools-chevron-menu")); + + chevronMenuButton = toolbox.doc.querySelector(".tools-chevron-menu"); + ok(chevronMenuButton, "The chevron menu button is displayed"); + + info( + "Open the tools-chevron-menupopup and verify that the inspector button is checked" + ); + await openChevronMenu(toolbox); + + const inspectorButton = toolbox.doc.querySelector( + "#tools-chevron-menupopup-inspector" + ); + ok(!inspectorButton, "The chevron menu doesn't have the inspector button."); + + const consoleButton = toolbox.doc.querySelector( + "#tools-chevron-menupopup-webconsole" + ); + ok(!consoleButton, "The chevron menu doesn't have the console button."); + + const storageButton = toolbox.doc.querySelector( + "#tools-chevron-menupopup-storage" + ); + ok(storageButton, "The chevron menu has the storage button."); + + info("Switch to the performance using the tools-chevron-menupopup popup"); + const onSelected = toolbox.once("storage-selected"); + storageButton.click(); + await onSelected; + + info("Restore the original window size"); + onResize = once(hostWindow, "resize"); + hostWindow.resizeTo(originalWidth, originalHeight); + await onResize; +}); diff --git a/devtools/client/framework/test/browser_toolbox_toolbar_overflow_button_visibility.js b/devtools/client/framework/test/browser_toolbox_toolbar_overflow_button_visibility.js new file mode 100644 index 0000000000..f266991109 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toolbar_overflow_button_visibility.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the toolbox tabs rearrangement when the visibility of toolbox buttons were changed. + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + const tab = await addTab("about:blank"); + const toolbox = await openToolboxForTab( + tab, + "options", + Toolbox.HostType.BOTTOM + ); + const toolboxButtonPreferences = toolbox.toolbarButtons.map( + button => button.visibilityswitch + ); + + const win = getWindow(toolbox); + const { outerWidth: originalWindowWidth, outerHeight: originalWindowHeight } = + win; + registerCleanupFunction(() => { + for (const preference of toolboxButtonPreferences) { + Services.prefs.clearUserPref(preference); + } + + win.resizeTo(originalWindowWidth, originalWindowHeight); + }); + + const optionsTool = toolbox.getCurrentPanel(); + const checkButtons = optionsTool.panelWin.document.querySelectorAll( + "#enabled-toolbox-buttons-box input[type=checkbox]" + ); + + info( + "Test the count of shown devtools tab after making all buttons to be visible" + ); + await resizeWindow(toolbox, 800); + // Once, make all toolbox button to be invisible. + setToolboxButtonsVisibility(checkButtons, false); + // Get count of shown devtools tab elements. + const initialTabCount = toolbox.doc.querySelectorAll(".devtools-tab").length; + // Make all toolbox button to be visible. + setToolboxButtonsVisibility(checkButtons, true); + Assert.less( + toolbox.doc.querySelectorAll(".devtools-tab").length, + initialTabCount, + "Count of shown devtools tab should decreased" + ); + + info( + "Test the count of shown devtools tab after making all buttons to be invisible" + ); + setToolboxButtonsVisibility(checkButtons, false); + is( + toolbox.doc.querySelectorAll(".devtools-tab").length, + initialTabCount, + "Count of shown devtools tab should be same to 1st count" + ); +}); + +function setToolboxButtonsVisibility(checkButtons, doVisible) { + for (const checkButton of checkButtons) { + if (checkButton.checked === doVisible) { + continue; + } + + checkButton.click(); + } +} diff --git a/devtools/client/framework/test/browser_toolbox_toolbar_reorder_by_dnd.js b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_by_dnd.js new file mode 100644 index 0000000000..9ef82ca6e9 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_by_dnd.js @@ -0,0 +1,188 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following reordering operation: +// * DragAndDrop the target component to back +// * DragAndDrop the target component to front +// * DragAndDrop the target component over the starting of the tab +// * DragAndDrop the target component over the ending of the tab +// * Mouse was out from the document while dragging +// * Select overflowed item, then DnD that +// +// This test is on the assumption which default toolbar has following tools. +// * inspector +// * webconsole +// * jsdebugger +// * styleeditor +// * performance +// * memory +// * netmonitor +// * storage +// * accessibility +// * application + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +const TEST_STARTING_ORDER = [ + "inspector", + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", +]; +const TEST_DATA = [ + { + description: "DragAndDrop the target component to back", + dragTarget: "webconsole", + dropTarget: "jsdebugger", + expectedOrder: [ + "inspector", + "jsdebugger", + "webconsole", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + }, + { + description: "DragAndDrop the target component to front", + dragTarget: "webconsole", + dropTarget: "inspector", + expectedOrder: [ + "webconsole", + "inspector", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + }, + { + description: + "DragAndDrop the target component over the starting of the tab", + dragTarget: "netmonitor", + passedTargets: [ + "memory", + "performance", + "styleeditor", + "jsdebugger", + "webconsole", + "inspector", + ], + dropTarget: "#toolbox-buttons-start", + expectedOrder: [ + "netmonitor", + "inspector", + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "storage", + "accessibility", + "application", + ], + }, + { + description: "DragAndDrop the target component over the ending of the tab", + dragTarget: "webconsole", + passedTargets: [ + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + ], + dropTarget: "#toolbox-buttons-end", + expectedOrder: [ + "inspector", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + "webconsole", + ], + }, +]; + +add_task(async function () { + // Enable the Application panel (atm it's only available on Nightly) + await pushPref("devtools.application.enabled", true); + + const tab = await addTab("about:blank"); + const toolbox = await openToolboxForTab( + tab, + "inspector", + Toolbox.HostType.BOTTOM + ); + + const originalPreference = Services.prefs.getCharPref( + "devtools.toolbox.tabsOrder" + ); + const win = getWindow(toolbox); + const { outerWidth: originalWindowWidth, outerHeight: originalWindowHeight } = + win; + registerCleanupFunction(() => { + Services.prefs.setCharPref( + "devtools.toolbox.tabsOrder", + originalPreference + ); + win.resizeTo(originalWindowWidth, originalWindowHeight); + }); + + for (const testData of TEST_DATA) { + info(`Test for '${testData.description}'`); + prepareToolTabReorderTest(toolbox, TEST_STARTING_ORDER); + await dndToolTab( + toolbox, + testData.dragTarget, + testData.dropTarget, + testData.passedTargets + ); + assertToolTabOrder(toolbox, testData.expectedOrder); + assertToolTabSelected(toolbox, testData.dragTarget); + assertToolTabPreferenceOrder(testData.expectedOrder); + } + + info("Test with overflowing tabs"); + prepareToolTabReorderTest(toolbox, TEST_STARTING_ORDER); + await resizeWindow(toolbox, 800); + await toolbox.selectTool("storage"); + const dragTarget = "storage"; + const dropTarget = "inspector"; + const expectedOrder = [ + "storage", + "inspector", + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "accessibility", + "application", + ]; + await dndToolTab(toolbox, dragTarget, dropTarget); + assertToolTabSelected(toolbox, dragTarget); + assertToolTabPreferenceOrder(expectedOrder); +}); diff --git a/devtools/client/framework/test/browser_toolbox_toolbar_reorder_by_width.js b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_by_width.js new file mode 100644 index 0000000000..3a8cd61d12 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_by_width.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This test will: +// +// * Confirm that currently selected button to access tools will not hide due to overflow. +// In this case, a button which is located on the left of a currently selected will hide. +// * Confirm that a button to access tool will hide when registering a new panel. +// +// Note that this test is based on the tab ordinal is fixed. +// i.e. After changed by Bug 1226272, this test might fail. + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + const tab = await addTab("about:blank"); + + info("Open devtools on the Storage in a sidebar."); + const toolbox = await openToolboxForTab( + tab, + "storage", + Toolbox.HostType.BOTTOM + ); + + const win = getWindow(toolbox); + const { outerWidth: originalWindowWidth, outerHeight: originalWindowHeight } = + win; + registerCleanupFunction(() => { + win.resizeTo(originalWindowWidth, originalWindowHeight); + }); + + info("Waiting for the window to be resized"); + await resizeWindow(toolbox, 800); + + info("Wait until the tools menu button is available"); + await waitUntil(() => toolbox.doc.querySelector(".tools-chevron-menu")); + + const toolsMenuButton = toolbox.doc.querySelector(".tools-chevron-menu"); + ok(toolsMenuButton, "The tools menu button is displayed"); + + info("Confirm that selected tab is not hidden."); + const storageButton = toolbox.doc.querySelector("#toolbox-tab-storage"); + ok(storageButton, "The storage tab is on toolbox."); + + // Reset window size for 2nd test. + await resizeWindow(toolbox, originalWindowWidth); +}); + +add_task(async function () { + const tab = await addTab("about:blank"); + + info("Open devtools on the Storage in a sidebar."); + const toolbox = await openToolboxForTab( + tab, + "storage", + Toolbox.HostType.BOTTOM + ); + + info("Resize devtools window to a width that should trigger an overflow"); + await resizeWindow(toolbox, 800); + + info("Regist a new tab"); + const onRegistered = toolbox.once("tool-registered"); + gDevTools.registerTool({ + id: "test-tools", + label: "Test Tools", + isMenu: true, + isToolSupported: () => true, + build() {}, + }); + await onRegistered; + + info("Open the tools menu button."); + await openChevronMenu(toolbox); + + info("The registered new tool tab should be in the tools menu."); + let testToolsButton = toolbox.doc.querySelector( + "#tools-chevron-menupopup-test-tools" + ); + ok(testToolsButton, "The tools menu has a registered new tool button."); + + await closeChevronMenu(toolbox); + + info("Unregistering test-tools"); + const onUnregistered = toolbox.once("tool-unregistered"); + gDevTools.unregisterTool("test-tools"); + await onUnregistered; + + info("Open the tools menu button."); + await openChevronMenu(toolbox); + + info("An unregistered new tool tab should not be in the tools menu."); + testToolsButton = toolbox.doc.querySelector( + "#tools-chevron-menupopup-test-tools" + ); + ok( + !testToolsButton, + "The tools menu doesn't have a unregistered new tool button." + ); + + await closeChevronMenu(toolbox); +}); diff --git a/devtools/client/framework/test/browser_toolbox_toolbar_reorder_with_extension.js b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_with_extension.js new file mode 100644 index 0000000000..d00aca4b0f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_with_extension.js @@ -0,0 +1,150 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for reordering with an extension installed. + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +const EXTENSION = "@reorder.test"; + +const TEST_STARTING_ORDER = [ + "inspector", + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + EXTENSION, +]; + +add_task(async function () { + // Enable the Application panel (atm it's only available on Nightly) + await pushPref("devtools.application.enabled", true); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + devtools_page: "extension.html", + browser_specific_settings: { + gecko: { id: EXTENSION }, + }, + }, + files: { + "extension.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="extension.js"></script> + </body> + </html>`, + "extension.js": async () => { + // eslint-disable-next-line no-undef + await browser.devtools.panels.create( + "extension", + "fake-icon.png", + "empty.html" + ); + // eslint-disable-next-line no-undef + browser.test.sendMessage("devtools-page-ready"); + }, + "empty.html": "", + }, + }); + + await extension.startup(); + + const tab = await addTab("about:blank"); + const toolbox = await openToolboxForTab( + tab, + "webconsole", + Toolbox.HostType.BOTTOM + ); + await extension.awaitMessage("devtools-page-ready"); + + const originalPreference = Services.prefs.getCharPref( + "devtools.toolbox.tabsOrder" + ); + const win = getWindow(toolbox); + const { outerWidth: originalWindowWidth, outerHeight: originalWindowHeight } = + win; + registerCleanupFunction(() => { + Services.prefs.setCharPref( + "devtools.toolbox.tabsOrder", + originalPreference + ); + win.resizeTo(originalWindowWidth, originalWindowHeight); + }); + + info("Test for DragAndDrop the extension tab"); + let dragTarget = EXTENSION; + let dropTarget = "webconsole"; + let expectedOrder = [ + "inspector", + EXTENSION, + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ]; + prepareToolTabReorderTest(toolbox, TEST_STARTING_ORDER); + await dndToolTab(toolbox, dragTarget, dropTarget); + assertToolTabOrder(toolbox, expectedOrder); + assertToolTabSelected(toolbox, dragTarget); + assertToolTabPreferenceOrder(expectedOrder); + + info("Test the case of that the extension tab is overflowed"); + prepareToolTabReorderTest(toolbox, TEST_STARTING_ORDER); + await resizeWindow(toolbox, 800); + await toolbox.selectTool("storage"); + dragTarget = "storage"; + dropTarget = "inspector"; + expectedOrder = [ + "storage", + "inspector", + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "accessibility", + "application", + EXTENSION, + ]; + await dndToolTab(toolbox, dragTarget, dropTarget); + assertToolTabPreferenceOrder(expectedOrder); + await resizeWindow(toolbox, originalWindowWidth, originalWindowHeight); + + info("Test the preference after uninstalling extension"); + prepareToolTabReorderTest(toolbox, TEST_STARTING_ORDER); + await extension.unload(); + dragTarget = "webconsole"; + dropTarget = "inspector"; + expectedOrder = [ + "webconsole", + "inspector", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ]; + await dndToolTab(toolbox, dragTarget, dropTarget); + assertToolTabPreferenceOrder(expectedOrder); +}); diff --git a/devtools/client/framework/test/browser_toolbox_toolbar_reorder_with_hidden_extension.js b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_with_hidden_extension.js new file mode 100644 index 0000000000..6e7b44d5d3 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_with_hidden_extension.js @@ -0,0 +1,248 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for reordering with an hidden extension installed. + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +const EXTENSION = "@reorder.test"; + +const TEST_DATA = [ + { + description: "Test that drags a tab to left beyond the extension's tab", + startingOrder: [ + "inspector", + EXTENSION, + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + dragTarget: "webconsole", + dropTarget: "inspector", + expectedOrder: [ + "webconsole", + "inspector", + EXTENSION, + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + }, + { + description: "Test that drags a tab to right beyond the extension's tab", + startingOrder: [ + "inspector", + EXTENSION, + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + dragTarget: "inspector", + dropTarget: "webconsole", + expectedOrder: [ + EXTENSION, + "webconsole", + "inspector", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + }, + { + description: + "Test that drags a tab to left end, but hidden tab is left end", + startingOrder: [ + EXTENSION, + "inspector", + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + dragTarget: "webconsole", + dropTarget: "inspector", + expectedOrder: [ + EXTENSION, + "webconsole", + "inspector", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + }, + { + description: + "Test that drags a tab to right end, but hidden tab is right end", + startingOrder: [ + "inspector", + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + EXTENSION, + ], + dragTarget: "webconsole", + dropTarget: "application", + expectedOrder: [ + "inspector", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + EXTENSION, + "webconsole", + ], + }, +]; + +add_task(async function () { + // Enable the Application panel (atm it's only available on Nightly) + await pushPref("devtools.application.enabled", true); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.toolbox.tabsOrder"); + }); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + devtools_page: "extension.html", + browser_specific_settings: { + gecko: { id: EXTENSION }, + }, + }, + files: { + "extension.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="extension.js"></script> + </body> + </html>`, + "extension.js": async () => { + // Don't call browser.devtools.panels.create since this need to be as hidden. + // eslint-disable-next-line + browser.test.sendMessage("devtools-page-ready"); + }, + }, + }); + + await extension.startup(); + + const tab = await addTab("about:blank"); + const toolbox = await openToolboxForTab( + tab, + "webconsole", + Toolbox.HostType.BOTTOM + ); + await extension.awaitMessage("devtools-page-ready"); + + for (const { + description, + startingOrder, + dragTarget, + dropTarget, + expectedOrder, + } of TEST_DATA) { + info(description); + prepareTestWithHiddenExtension(toolbox, startingOrder); + await dndToolTab(toolbox, dragTarget, dropTarget); + assertToolTabPreferenceOrder(expectedOrder); + } + + info("Test ordering preference after uninstalling hidden addon"); + const startingOrder = [ + "inspector", + EXTENSION, + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ]; + const dragTarget = "webconsole"; + const dropTarget = "inspector"; + const expectedOrder = [ + "webconsole", + "inspector", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ]; + prepareTestWithHiddenExtension(toolbox, startingOrder); + await extension.unload(); + await dndToolTab(toolbox, dragTarget, dropTarget); + assertToolTabPreferenceOrder(expectedOrder); +}); + +function prepareTestWithHiddenExtension(toolbox, startingOrder) { + Services.prefs.setCharPref( + "devtools.toolbox.tabsOrder", + startingOrder.join(",") + ); + + for (const id of startingOrder) { + if (id === EXTENSION) { + ok( + !getElementByToolId(toolbox, id), + "Hidden extension tab should not exist" + ); + } else { + ok(getElementByToolId(toolbox, id), `Tab element should exist for ${id}`); + } + } +} diff --git a/devtools/client/framework/test/browser_toolbox_tools_per_toolbox_registration.js b/devtools/client/framework/test/browser_toolbox_tools_per_toolbox_registration.js new file mode 100644 index 0000000000..0e9009497f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_tools_per_toolbox_registration.js @@ -0,0 +1,138 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = `data:text/html,<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + test for registering and unregistering tools to a specific toolbox + </body> + </html>`; + +const TOOL_ID = "test-toolbox-tool"; +var toolbox; + +function test() { + addTab(TEST_URL).then(async tab => { + gDevTools + .showToolboxForTab(tab) + .then(toolboxRegister) + .then(testToolRegistered); + }); +} + +var resolveToolInstanceBuild; +var waitForToolInstanceBuild = new Promise(resolve => { + resolveToolInstanceBuild = resolve; +}); + +var resolveToolInstanceDestroyed; +var waitForToolInstanceDestroyed = new Promise(resolve => { + resolveToolInstanceDestroyed = resolve; +}); + +function toolboxRegister(aToolbox) { + toolbox = aToolbox; + + waitForToolInstanceBuild = new Promise(resolve => { + resolveToolInstanceBuild = resolve; + }); + + info("add per-toolbox tool in the opened toolbox."); + + toolbox.addAdditionalTool({ + id: TOOL_ID, + // The size of the label can make the test fail if it's too long. + // See ok(tab, ...) assert below and Bug 1596345. + label: "Test Tool", + inMenu: true, + isToolSupported: () => true, + build() { + info("per-toolbox tool has been built."); + resolveToolInstanceBuild(); + + return { + destroy: () => { + info("per-toolbox tool has been destroyed."); + resolveToolInstanceDestroyed(); + }, + }; + }, + key: "t", + }); +} + +function testToolRegistered() { + ok( + !gDevTools.getToolDefinitionMap().has(TOOL_ID), + "per-toolbox tool is not registered globally" + ); + ok( + toolbox.hasAdditionalTool(TOOL_ID), + "per-toolbox tool registered to the specific toolbox" + ); + + // Test that the tool appeared in the UI. + const doc = toolbox.doc; + const tab = getToolboxTab(doc, TOOL_ID); + + ok(tab, "new tool's tab exists in toolbox UI"); + + const panel = doc.getElementById("toolbox-panel-" + TOOL_ID); + ok(panel, "new tool's panel exists in toolbox UI"); + + for (const win of getAllBrowserWindows()) { + const key = win.document.getElementById("key_" + TOOL_ID); + if (win.document == doc) { + continue; + } + ok(!key, "key for new tool should not exists in the other browser windows"); + const menuitem = win.document.getElementById("menuitem_" + TOOL_ID); + ok(!menuitem, "menu item should not exists in the other browser window"); + } + + // Test that the tool is built once selected and then test its unregistering. + info("select per-toolbox tool in the opened toolbox."); + gDevTools + .showToolboxForTab(gBrowser.selectedTab, { toolId: TOOL_ID }) + .then(waitForToolInstanceBuild) + .then(testUnregister); +} + +function getAllBrowserWindows() { + return Array.from(Services.wm.getEnumerator("navigator:browser")); +} + +function testUnregister() { + info("remove per-toolbox tool in the opened toolbox."); + toolbox.removeAdditionalTool(TOOL_ID); + + Promise.all([waitForToolInstanceDestroyed]).then(toolboxToolUnregistered); +} + +function toolboxToolUnregistered() { + ok( + !toolbox.hasAdditionalTool(TOOL_ID), + "per-toolbox tool unregistered from the specific toolbox" + ); + + // test that it disappeared from the UI + const doc = toolbox.doc; + const tab = getToolboxTab(doc, TOOL_ID); + ok(!tab, "tool's tab was removed from the toolbox UI"); + + const panel = doc.getElementById("toolbox-panel-" + TOOL_ID); + ok(!panel, "tool's panel was removed from toolbox UI"); + + cleanup(); +} + +function cleanup() { + toolbox.destroy().then(() => { + toolbox = null; + gBrowser.removeCurrentTab(); + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_view_source_01.js b/devtools/client/framework/test/browser_toolbox_view_source_01.js new file mode 100644 index 0000000000..f1a0924cf9 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_view_source_01.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that Toolbox#viewSourceInDebugger works when debugger is not + * yet opened. + */ + +var URL = `${URL_ROOT_SSL}doc_viewsource.html`; +var JS_URL = `${URL_ROOT_SSL}code_math.js`; + +async function viewSource() { + const toolbox = await openNewTabAndToolbox(URL); + + await toolbox.viewSourceInDebugger(JS_URL, 2); + + const debuggerPanel = toolbox.getPanel("jsdebugger"); + ok(debuggerPanel, "The debugger panel was opened."); + is(toolbox.currentToolId, "jsdebugger", "The debugger panel was selected."); + + assertSelectedLocationInDebugger(debuggerPanel, 2, undefined); + await closeToolboxAndTab(toolbox); + finish(); +} + +function test() { + viewSource().then(finish, aError => { + ok(false, "Got an error: " + aError.message + "\n" + aError.stack); + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_view_source_02.js b/devtools/client/framework/test/browser_toolbox_view_source_02.js new file mode 100644 index 0000000000..25bf0c2717 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_view_source_02.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that Toolbox#viewSourceInDebugger works when debugger is already loaded. + */ + +var URL = `${URL_ROOT_SSL}doc_viewsource.html`; +var JS_URL = `${URL_ROOT_SSL}code_math.js`; + +async function viewSource() { + const toolbox = await openNewTabAndToolbox(URL); + await toolbox.selectTool("jsdebugger"); + + await toolbox.viewSourceInDebugger(JS_URL, 2); + + const debuggerPanel = toolbox.getPanel("jsdebugger"); + ok(debuggerPanel, "The debugger panel was opened."); + is(toolbox.currentToolId, "jsdebugger", "The debugger panel was selected."); + + assertSelectedLocationInDebugger(debuggerPanel, 2, undefined); + + // See Bug 1637793 and Bug 1621337. + // Ideally the debugger should only resolve when the worker targets have been + // retrieved, which should be fixed by Bug 1621337 or a followup. + info("Wait for all pending requests to settle on the DevToolsClient"); + await toolbox.commands.client.waitForRequestsToSettle(); + + await closeToolboxAndTab(toolbox); + finish(); +} + +function test() { + viewSource().then(finish, aError => { + ok(false, "Got an error: " + aError.message + "\n" + aError.stack); + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_view_source_03.js b/devtools/client/framework/test/browser_toolbox_view_source_03.js new file mode 100644 index 0000000000..dce9ff8840 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_view_source_03.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that Toolbox#viewSourceInStyleEditor works when style editor is not + * yet opened. + */ + +var URL = `${URL_ROOT_SSL}doc_viewsource.html`; +var CSS_URL = `${URL_ROOT_SSL}doc_theme.css`; + +async function viewSource() { + const toolbox = await openNewTabAndToolbox(URL); + + const fileFound = await toolbox.viewSourceInStyleEditorByURL(CSS_URL, 2); + ok( + fileFound, + "viewSourceInStyleEditorByURL should resolve to true if source found." + ); + + const stylePanel = toolbox.getPanel("styleeditor"); + ok(stylePanel, "The style editor panel was opened."); + is( + toolbox.currentToolId, + "styleeditor", + "The style editor panel was selected." + ); + + const { UI } = stylePanel; + + is( + UI.selectedEditor.styleSheet.href, + CSS_URL, + "The correct source is shown in the style editor." + ); + is( + UI.selectedEditor.sourceEditor.getCursor().line + 1, + 2, + "The correct line is highlighted in the style editor's source editor." + ); + + await closeToolboxAndTab(toolbox); + finish(); +} + +function test() { + viewSource().then(finish, aError => { + ok(false, "Got an error: " + aError.message + "\n" + aError.stack); + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_view_source_style_editor_fallback.js b/devtools/client/framework/test/browser_toolbox_view_source_style_editor_fallback.js new file mode 100644 index 0000000000..b895d0a80e --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_view_source_style_editor_fallback.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that Toolbox#viewSourceInStyleEditor does fall back to view-source + */ + +const TEST_URL = `data:text/html,<!DOCTYPE html><meta charset=utf8>Got no style`; +const CSS_URL = `${URL_ROOT_SSL}doc_theme.css`; + +add_task(async function () { + // start on webconsole since it doesn't have much activity so we're less vulnerable + // to pending promises. + const toolbox = await openNewTabAndToolbox(TEST_URL, "webconsole"); + + const onTabOpen = BrowserTestUtils.waitForNewTab( + gBrowser, + url => url == `view-source:${CSS_URL}`, + true + ); + + info("View source of an existing file that isn't used by the page"); + const fileFound = await toolbox.viewSourceInStyleEditorByURL(CSS_URL, 0); + ok( + !fileFound, + "viewSourceInStyleEditorByURL should resolve to false if source isn't found." + ); + + info("Waiting for view-source tab to open"); + const viewSourceTab = await onTabOpen; + ok(true, "The view source tab was opened"); + await removeTab(viewSourceTab); + + info("Check that the current panel is the console"); + is(toolbox.currentToolId, "webconsole", "Console is still selected"); + + await closeToolboxAndTab(toolbox); +}); diff --git a/devtools/client/framework/test/browser_toolbox_watchedByDevTools.js b/devtools/client/framework/test/browser_toolbox_watchedByDevTools.js new file mode 100644 index 0000000000..a58b57885d --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_watchedByDevTools.js @@ -0,0 +1,72 @@ +/* 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_HTTP_URI = + "http://mochi.test:8888/document-builder.sjs?html=<div id=http>http"; +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 () { + const tab = await addTab(EXAMPLE_HTTP_URI); + + is( + tab.linkedBrowser.browsingContext.watchedByDevTools, + false, + "watchedByDevTools isn't set when DevTools aren't opened" + ); + + info( + "Open a toolbox for the opened tab and check that watchedByDevTools is set" + ); + await gDevTools.showToolboxForTab(tab, { toolId: "options" }); + + is( + tab.linkedBrowser.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is set after opening a toolbox" + ); + + info( + "Check that watchedByDevTools persist when the tab navigates to a different origin" + ); + await navigateTo(EXAMPLE_COM_URI); + + is( + tab.linkedBrowser.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is still set after navigating to a different origin" + ); + + info( + "Check that watchedByDevTools persist when navigating to a page that creates a new browsing context" + ); + const previousBrowsingContextId = tab.linkedBrowser.browsingContext.id; + await navigateTo(EXAMPLE_ORG_URI); + + isnot( + tab.linkedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + tab.linkedBrowser.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is still set after navigating to a new browsing context" + ); + + info("Check that the flag is reset when the toolbox is closed"); + await gDevTools.closeToolboxForTab(tab); + is( + tab.linkedBrowser.browsingContext.watchedByDevTools, + false, + "watchedByDevTools is reset after closing the toolbox" + ); +}); diff --git a/devtools/client/framework/test/browser_toolbox_window_reload_target.js b/devtools/client/framework/test/browser_toolbox_window_reload_target.js new file mode 100644 index 0000000000..d9a4eb34c1 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_reload_target.js @@ -0,0 +1,130 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that pressing various page reload keyboard shortcuts always works when devtools +// has focus, no matter if it's undocked or docked, and whatever the tool selected (this +// is to avoid tools from overriding the page reload shortcuts). +// This test also serves as a safety net checking that tools just don't explode when the +// page is reloaded. +// It is therefore quite long to run. + +requestLongerTimeout(10); +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); + +// allow a context error because it is harmless. This could likely be removed in the next patch because it is a symptom of events coming from the target-list and debugger targets module... +PromiseTestUtils.allowMatchingRejectionsGlobally(/Page has navigated/); + +const TEST_URL = + "data:text/html;charset=utf-8," + + "<html><head><title>Test reload</title></head>" + + "<body><h1>Testing reload from devtools</h1></body></html>"; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +// Track how many page reloads we've sent to the page. +var reloadsSent = 0; + +add_task(async function () { + await addTab(TEST_URL); + const tab = gBrowser.selectedTab; + const toolIDs = await getSupportedToolIds(tab); + + info( + "Display the toolbox, docked at the bottom, with the first tool selected" + ); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: toolIDs[0], + hostType: Toolbox.HostType.BOTTOM, + }); + + info( + "Listen to page reloads to check that they are indeed sent by the toolbox" + ); + let reloadDetected = 0; + const reloadCounter = msg => { + reloadDetected++; + info("Detected reload #" + reloadDetected); + is( + reloadDetected, + reloadsSent, + "Detected the right number of reloads in the page" + ); + }; + + const removeLoadListener = BrowserTestUtils.addContentEventListener( + gBrowser.selectedBrowser, + "load", + reloadCounter, + {} + ); + + info("Start testing with the toolbox docked"); + // Note that we actually only test 1 tool in docked mode, to cut down on test time. + await testOneTool(toolbox, toolIDs[toolIDs.length - 1]); + + info("Switch to undocked mode"); + await toolbox.switchHost(Toolbox.HostType.WINDOW); + toolbox.win.focus(); + + info("Now test with the toolbox undocked"); + for (const toolID of toolIDs) { + await testOneTool(toolbox, toolID); + } + + info("Switch back to docked mode"); + await toolbox.switchHost(Toolbox.HostType.BOTTOM); + + removeLoadListener(); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function testOneTool(toolbox, toolID) { + info(`Select tool ${toolID}`); + await toolbox.selectTool(toolID); + + assertThemeStyleSheet(toolbox, toolID); + + await testReload("toolbox.reload.key", toolbox); + await testReload("toolbox.reload2.key", toolbox); + await testReload("toolbox.forceReload.key", toolbox); + await testReload("toolbox.forceReload2.key", toolbox); +} + +async function testReload(shortcut, toolbox) { + info(`Reload with ${shortcut}`); + + await sendToolboxReloadShortcut(L10N.getStr(shortcut), toolbox); + reloadsSent++; +} + +/** + * As opening all panels is an expensive operation, reuse this test in order + * to add a few assertions around panel's stylesheets. + * Ensure the proper ordering of the theme stylesheet. `global.css` should come + * first if it exists, then the theme. + */ +function assertThemeStyleSheet(toolbox, toolID) { + const iframe = toolbox.doc.getElementById("toolbox-panel-iframe-" + toolID); + const styleSheets = iframe.contentDocument.querySelectorAll( + `link[rel="stylesheet"]` + ); + ok( + !!styleSheets.length, + `The panel ${toolID} should have at least have one stylesheet` + ); + + // In the web console, we have a special case where global.css is registered very first + if (styleSheets[0].href === "chrome://global/skin/global.css") { + is(styleSheets[1].href, "chrome://devtools/skin/light-theme.css"); + } else { + // Otherwise, in all other panels, the theme file is registered very first + is(styleSheets[0].href, "chrome://devtools/skin/light-theme.css"); + } +} diff --git a/devtools/client/framework/test/browser_toolbox_window_reload_target_force.js b/devtools/client/framework/test/browser_toolbox_window_reload_target_force.js new file mode 100644 index 0000000000..d563fb7d37 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_reload_target_force.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Simple test page which writes the value of the cache-control header. +const TEST_URL = URL_ROOT + "sjs_cache_controle_header.sjs"; + +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +// Test that "forceReload" shorcuts send requests with the correct cache-control +// header value: no-cache. +add_task(async function () { + await addTab(TEST_URL); + const tab = gBrowser.selectedTab; + + info("Open the toolbox with the inspector selected"); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "inspector", + }); + + // The VALIDATE_ALWAYS flag isn’t going to be applied when we only revalidate + // the top level document, thus the expectedHeader is empty. + const expectedHeader = Services.prefs.getBoolPref( + "browser.soft_reload.only_force_validate_top_level_document", + false + ) + ? "" + : "max-age=0"; + await testReload("toolbox.reload.key", toolbox, expectedHeader); + await testReload("toolbox.reload2.key", toolbox, expectedHeader); + await testReload("toolbox.forceReload.key", toolbox, "no-cache"); + await testReload("toolbox.forceReload2.key", toolbox, "no-cache"); +}); + +async function testReload(shortcut, toolbox, expectedHeader) { + info(`Reload with ${shortcut}`); + await sendToolboxReloadShortcut(L10N.getStr(shortcut), toolbox); + + info("Retrieve the text content of the test page"); + const textContent = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function () { + return content.document.body.textContent; + } + ); + + // See sjs_cache_controle_header.sjs + is( + textContent, + "cache-control:" + expectedHeader, + "cache-control header for the page request had the expected value" + ); +} diff --git a/devtools/client/framework/test/browser_toolbox_window_shortcuts.js b/devtools/client/framework/test/browser_toolbox_window_shortcuts.js new file mode 100644 index 0000000000..53cea3a55a --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_shortcuts.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var Startup = Cc["@mozilla.org/devtools/startup-clh;1"].getService( + Ci.nsISupports +).wrappedJSObject; +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +var toolbox, + toolIDs, + toolShortcuts = [], + idIndex, + modifiedPrefs = []; + +async function test() { + addTab("about:blank").then(async function () { + toolIDs = []; + for (const [id, definition] of gDevTools._tools) { + const shortcut = Startup.KeyShortcuts.filter(s => s.toolId == id)[0]; + if (!shortcut) { + continue; + } + toolIDs.push(id); + toolShortcuts.push(shortcut); + + // Enable disabled tools + const pref = definition.visibilityswitch; + if (pref) { + const prefValue = Services.prefs.getBoolPref(pref, false); + if (!prefValue) { + modifiedPrefs.push(pref); + Services.prefs.setBoolPref(pref, true); + } + } + } + const tab = gBrowser.selectedTab; + idIndex = 0; + gDevTools + .showToolboxForTab(tab, { + toolId: toolIDs[0], + hostType: Toolbox.HostType.WINDOW, + }) + .then(testShortcuts); + }); +} + +function testShortcuts(aToolbox, aIndex) { + if (aIndex === undefined) { + aIndex = 1; + } else if (aIndex == toolIDs.length) { + tidyUp(); + return; + } + + toolbox = aToolbox; + info("Toolbox fired a `ready` event"); + + toolbox.once("select", selectCB); + + const shortcut = toolShortcuts[aIndex]; + const key = shortcut.shortcut; + const toolModifiers = shortcut.modifiers; + const modifiers = { + accelKey: toolModifiers.includes("accel"), + altKey: toolModifiers.includes("alt"), + shiftKey: toolModifiers.includes("shift"), + }; + idIndex = aIndex; + info( + "Testing shortcut for tool " + + aIndex + + ":" + + toolIDs[aIndex] + + " using key " + + key + ); + EventUtils.synthesizeKey(key, modifiers, toolbox.win.parent); +} + +function selectCB(id) { + info("toolbox-select event from " + id); + + is( + toolIDs.indexOf(id), + idIndex, + "Correct tool is selected on pressing the shortcut for " + id + ); + + testShortcuts(toolbox, idIndex + 1); +} + +function tidyUp() { + toolbox.destroy().then(function () { + gBrowser.removeCurrentTab(); + + for (const pref of modifiedPrefs) { + Services.prefs.clearUserPref(pref); + } + toolbox = toolIDs = idIndex = modifiedPrefs = Toolbox = null; + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_window_title_changes.js b/devtools/client/framework/test/browser_toolbox_window_title_changes.js new file mode 100644 index 0000000000..176b0b0f65 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_title_changes.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +requestLongerTimeout(5); + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +const NAME_1 = ""; +const NAME_2 = "Toolbox test for title update"; +const NAME_3 = NAME_2; +const NAME_4 = "Toolbox test for another title update"; + +const URL_1 = "data:text/plain;charset=UTF-8,abcde"; +const URL_2 = + URL_ROOT_ORG_SSL + "browser_toolbox_window_title_changes_page.html"; +const URL_3 = + URL_ROOT_COM_SSL + "browser_toolbox_window_title_changes_page.html"; +const URL_4 = `https://example.com/document-builder.sjs?html=<head><title>${NAME_4}</title></head><h1>Hello`; + +add_task(async function test() { + await addTab(URL_1); + + const tab = gBrowser.selectedTab; + let toolbox = await gDevTools.showToolboxForTab(tab, { + hostType: Toolbox.HostType.BOTTOM, + }); + await toolbox.selectTool("webconsole"); + + info("Undock toolbox and check title"); + // We have to first switch the host in order to spawn the new top level window + // on which we are going to listen from title change event + await toolbox.switchHost(Toolbox.HostType.WINDOW); + await checkTitle(NAME_1, URL_1, "toolbox undocked"); + + info("switch to different tool and check title again"); + await toolbox.selectTool("jsdebugger"); + await checkTitle(NAME_1, URL_1, "tool changed"); + + info("navigate to different local url and check title"); + + await navigateTo(URL_2); + info("wait for title change"); + await checkTitle(NAME_2, URL_2, "url changed"); + + info("navigate to a real url and check title"); + await navigateTo(URL_3); + + info("wait for title change"); + await checkTitle(NAME_3, URL_3, "url changed"); + + info("navigate to another page on the same domain"); + await navigateTo(URL_4); + await checkTitle(NAME_4, URL_4, "title changed"); + + info( + "destroy toolbox, create new one hosted in a window (with a different tool id), and check title" + ); + // Give the tools a chance to handle the navigation event before + // destroying the toolbox. + await new Promise(resolve => executeSoon(resolve)); + await toolbox.destroy(); + + // After destroying the toolbox, open a new one. + toolbox = await gDevTools.showToolboxForTab(tab, { + hostType: Toolbox.HostType.WINDOW, + }); + toolbox.selectTool("webconsole"); + await checkTitle(NAME_4, URL_4, "toolbox destroyed and recreated"); + + info("clean up"); + await toolbox.destroy(); + gBrowser.removeCurrentTab(); + Services.prefs.clearUserPref("devtools.toolbox.host"); + Services.prefs.clearUserPref("devtools.toolbox.selectedTool"); +}); + +function getExpectedTitle(name, url) { + if (name) { + return `Developer Tools — ${name} — ${url}`; + } + return `Developer Tools — ${url}`; +} + +async function checkTitle(name, url, context) { + info("Check title - " + context); + await waitFor( + () => getToolboxWindowTitle() === getExpectedTitle(name, url), + `Didn't get the expected title ("${getExpectedTitle(name, url)}"`, + 200, + 50 + ); + const expectedTitle = getExpectedTitle(name, url); + is(getToolboxWindowTitle(), expectedTitle, context); +} + +function getToolboxWindowTitle() { + return Services.wm.getMostRecentWindow("devtools:toolbox").document.title; +} diff --git a/devtools/client/framework/test/browser_toolbox_window_title_changes_page.html b/devtools/client/framework/test/browser_toolbox_window_title_changes_page.html new file mode 100644 index 0000000000..8678469ee5 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_title_changes_page.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="UTF-8"> + <title>Toolbox test for title update</title> + <!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> + </head> + <body></body> +</html> diff --git a/devtools/client/framework/test/browser_toolbox_window_title_frame_select.js b/devtools/client/framework/test/browser_toolbox_window_title_frame_select.js new file mode 100644 index 0000000000..06905fbd3b --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_title_frame_select.js @@ -0,0 +1,171 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that the detached devtools window title is not updated when switching + * the selected frame. Also check that frames command button has 'open' + * attribute set when the list of frames is opened. + */ + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); +const URL = + URL_ROOT_SSL + "browser_toolbox_window_title_frame_select_page.html"; +const IFRAME_URL = + URL_ROOT_SSL + "browser_toolbox_window_title_changes_page.html"; +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +/** + * Wait for a given toolbox to get its title updated. + */ +function waitForTitleChange(toolbox) { + return new Promise(resolve => { + toolbox.topWindow.addEventListener("message", function onmessage(event) { + if (event.data.name == "set-host-title") { + toolbox.topWindow.removeEventListener("message", onmessage); + resolve(); + } + }); + }); +} + +add_task(async function () { + Services.prefs.setBoolPref("devtools.command-button-frames.enabled", true); + + await addTab(URL); + const tab = gBrowser.selectedTab; + let toolbox = await gDevTools.showToolboxForTab(tab, { + hostType: Toolbox.HostType.BOTTOM, + }); + + await toolbox.switchHost(Toolbox.HostType.WINDOW); + // Wait for title change event *after* switch host, in order to listen + // for the event on the WINDOW host window, which only exists after switchHost + await waitForTitleChange(toolbox); + + is( + getTitle(), + `Developer Tools — Page title — ${URL}`, + "Devtools title correct after switching to detached window host" + ); + + // Wait for tick to avoid unexpected 'popuphidden' event, which + // blocks the frame popup menu opened below. See also bug 1276873 + await waitForTick(); + + const btn = toolbox.doc.getElementById("command-button-frames"); + + await testShortcutToOpenFrames(btn, toolbox); + + // Open frame menu and wait till it's available on the screen. + // Also check 'aria-expanded' attribute on the command button. + is( + btn.getAttribute("aria-expanded"), + "false", + "The aria-expanded attribute must be set to false" + ); + btn.click(); + + const panel = toolbox.doc.getElementById("command-button-frames-panel"); + ok(panel, "popup panel has created."); + await waitUntil(() => panel.classList.contains("tooltip-visible")); + + is( + btn.getAttribute("aria-expanded"), + "true", + "The aria-expanded attribute must be set to true" + ); + + // Verify that the frame list menu is populated + const menuList = toolbox.doc.getElementById("toolbox-frame-menu"); + const frames = Array.from(menuList.querySelectorAll(".command")); + is(frames.length, 2, "We have both frames in the list"); + + const topFrameBtn = frames.filter( + b => b.querySelector(".label").textContent == URL + )[0]; + const iframeBtn = frames.filter( + b => b.querySelector(".label").textContent == IFRAME_URL + )[0]; + ok(topFrameBtn, "Got top level document in the list"); + ok(iframeBtn, "Got iframe document in the list"); + + // Listen to will-navigate to check if the view is empty + const { resourceCommand } = toolbox.commands; + const { onResource: willNavigate } = + await resourceCommand.waitForNextResource( + resourceCommand.TYPES.DOCUMENT_EVENT, + { + ignoreExistingResources: true, + predicate(resource) { + return resource.name == "will-navigate"; + }, + } + ); + + // Only select the iframe after we are able to select an element from the top + // level document. + const onInspectorReloaded = toolbox.getPanel("inspector").once("reloaded"); + info("Select the iframe"); + iframeBtn.click(); + + // will-navigate isn't emitted in the targetCommand-based iframe picker. + if (!isEveryFrameTargetEnabled()) { + await willNavigate; + } + await onInspectorReloaded; + // wait a bit more in case an eventual title update would happen later + await wait(1000); + + info("Navigation to the iframe is done, the inspector should be back up"); + is( + getTitle(), + `Developer Tools — Page title — ${URL}`, + "Devtools title was not updated after changing inspected frame" + ); + + info("Cleanup toolbox and test preferences."); + await toolbox.destroy(); + toolbox = null; + gBrowser.removeCurrentTab(); + Services.prefs.clearUserPref("devtools.toolbox.host"); + Services.prefs.clearUserPref("devtools.toolbox.selectedTool"); + Services.prefs.clearUserPref("devtools.command-button-frames.enabled"); + finish(); +}); + +function getTitle() { + return Services.wm.getMostRecentWindow("devtools:toolbox").document.title; +} + +async function testShortcutToOpenFrames(btn, toolbox) { + info("Tests if shortcut Alt+Down opens the frames"); + // focus the button so that keyPress can be performed + btn.focus(); + // perform keyPress - Alt+Down + const shortcut = L10N.getStr("toolbox.showFrames.key"); + synthesizeKeyShortcut(shortcut, toolbox.win); + + const panel = toolbox.doc.getElementById("command-button-frames-panel"); + ok(panel, "popup panel has created."); + await waitUntil(() => panel.classList.contains("tooltip-visible")); + + is( + btn.getAttribute("aria-expanded"), + "true", + "The aria-expanded attribute must be set to true" + ); + + // pressing Esc should hide the menu again + EventUtils.sendKey("ESCAPE", toolbox.win); + await waitUntil(() => !panel.classList.contains("tooltip-visible")); + + is( + btn.getAttribute("aria-expanded"), + "false", + "The aria-expanded attribute must be set to false" + ); +} diff --git a/devtools/client/framework/test/browser_toolbox_window_title_frame_select_page.html b/devtools/client/framework/test/browser_toolbox_window_title_frame_select_page.html new file mode 100644 index 0000000000..1eda94a9cf --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_title_frame_select_page.html @@ -0,0 +1,11 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="UTF-8"> + <title>Page title</title> + <!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> + <iframe src="browser_toolbox_window_title_changes_page.html"></iframe> + </head> + <body></body> +</html> diff --git a/devtools/client/framework/test/browser_toolbox_zoom.js b/devtools/client/framework/test/browser_toolbox_zoom.js new file mode 100644 index 0000000000..6db94900f9 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_zoom.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +add_task(async function () { + registerCleanupFunction(function () { + Services.prefs.clearUserPref("devtools.toolbox.zoomValue"); + }); + + // This test assume that zoom value will be default value. i.e. x1.0. + Services.prefs.setCharPref("devtools.toolbox.zoomValue", "1.0"); + await addTab("about:blank"); + const toolbox = await gDevTools.showToolboxForTab(gBrowser.selectedTab, { + toolId: "styleeditor", + hostType: Toolbox.HostType.BOTTOM, + }); + + info("testing zoom keys"); + + testZoomLevel("In", 2, 1.2, toolbox); + testZoomLevel("Out", 3, 0.9, toolbox); + testZoomLevel("Reset", 1, 1, toolbox); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +function testZoomLevel(type, times, expected, toolbox) { + sendZoomKey("toolbox.zoom" + type + ".key", times); + + const zoom = getCurrentZoom(toolbox); + is( + zoom.toFixed(1), + expected.toFixed(1), + "zoom level correct after zoom " + type + ); + + const savedZoom = parseFloat( + Services.prefs.getCharPref("devtools.toolbox.zoomValue") + ); + is( + savedZoom.toFixed(1), + expected.toFixed(1), + "saved zoom level is correct after zoom " + type + ); +} + +function sendZoomKey(shortcut, times) { + for (let i = 0; i < times; i++) { + synthesizeKeyShortcut(L10N.getStr(shortcut)); + } +} + +function getCurrentZoom(toolbox) { + return toolbox.win.browsingContext.fullZoom; +} diff --git a/devtools/client/framework/test/browser_toolbox_zoom_popup.js b/devtools/client/framework/test/browser_toolbox_zoom_popup.js new file mode 100644 index 0000000000..7b764bf703 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_zoom_popup.js @@ -0,0 +1,213 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the popup menu position when zooming in the devtools panel. + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +// Use a simple URL in order to prevent displacing the left position of the +// frames menu. +const TEST_URL = "data:text/html;charset=utf-8,<iframe/>"; + +add_task(async function () { + registerCleanupFunction(async function () { + Services.prefs.clearUserPref("devtools.toolbox.zoomValue"); + }); + const zoom = 1.4; + Services.prefs.setCharPref("devtools.toolbox.zoomValue", zoom.toString(10)); + + info("Load iframe page for checking the frame menu with x1.4 zoom."); + await addTab(TEST_URL); + const tab = gBrowser.selectedTab; + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "inspector", + hostType: Toolbox.HostType.WINDOW, + }); + const inspector = toolbox.getCurrentPanel(); + const hostWindow = toolbox.win.parent; + const originWidth = hostWindow.outerWidth; + const originHeight = hostWindow.outerHeight; + + info(`Waiting for the toolbox window will to be rendered with zoom x${zoom}`); + await waitUntil(() => { + return parseFloat(toolbox.win.browsingContext.fullZoom.toFixed(1)) === zoom; + }); + + info( + "Resizing and moving the toolbox window in order to display the chevron menu." + ); + // If the window is displayed bottom of screen, the menu might be displayed + // above the button so move it to the top of the screen first. + await moveWindowTo(hostWindow, 10, 10); + + // Shrink the width of the window such that the inspector's tab menu button + // and chevron button are visible. + const prevTabs = toolbox.doc.querySelectorAll(".devtools-tab").length; + info("Shrinking window"); + + hostWindow.resizeTo(400, hostWindow.outerHeight); + await waitUntil(() => { + info(`Waiting for chevron(${hostWindow.outerWidth})`); + return ( + hostWindow.outerWidth === 400 && + toolbox.doc.getElementById("tools-chevron-menu-button") && + inspector.panelDoc.querySelector(".all-tabs-menu") && + prevTabs != toolbox.doc.querySelectorAll(".devtools-tab").length + ); + }); + + const menuList = [ + toolbox.win.document.getElementById("toolbox-meatball-menu-button"), + toolbox.win.document.getElementById("command-button-frames"), + toolbox.win.document.getElementById("tools-chevron-menu-button"), + inspector.panelDoc.querySelector(".all-tabs-menu"), + ]; + + for (const menu of menuList) { + const { buttonBounds, menuType, menuBounds, arrowBounds } = + await getButtonAndMenuInfo(toolbox, menu); + + switch (menuType) { + case "native": + { + // Allow rounded error and platform offset value. + // horizontal : IntID::ContextMenuOffsetHorizontal of GTK and Windows + // uses 2. + // vertical: IntID::ContextMenuOffsetVertical of macOS uses -6. + const xDelta = Math.abs(menuBounds.left - buttonBounds.left); + const yDelta = Math.abs(menuBounds.top - buttonBounds.bottom); + Assert.less( + xDelta, + 2, + "xDelta is lower than 2: " + xDelta + ". #" + menu.id + ); + Assert.less( + yDelta, + 6, + "yDelta is lower than 6: " + yDelta + ". #" + menu.id + ); + } + break; + + case "doorhanger": + { + // Calculate the center of the button and center of the arrow and + // check they align. + const buttonCenter = buttonBounds.left + buttonBounds.width / 2; + const arrowCenter = arrowBounds.left + arrowBounds.width / 2; + const delta = Math.abs(arrowCenter - buttonCenter); + Assert.lessOrEqual( + Math.round(delta), + 1, + "Center of arrow is within 1px of button center" + + ` (delta: ${delta})` + ); + } + break; + } + } + + const onResize = once(hostWindow, "resize"); + hostWindow.resizeTo(originWidth, originHeight); + await onResize; + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +function convertScreenToDoc(popup, doc) { + const rect = popup.getOuterScreenRect(); + const screenX = doc.defaultView.mozInnerScreenX; + const screenY = doc.defaultView.mozInnerScreenY; + const scale = + popup.ownerGlobal.devicePixelRatio / doc.ownerGlobal.devicePixelRatio; + return new DOMRect( + rect.x * scale - screenX, + rect.y * scale - screenY, + rect.width * scale, + rect.height * scale + ); +} + +/** + * Get the bounds of a menu button and its popup panel. The popup panel is + * measured by clicking the menu button and looking for its panel (and then + * hiding it again). + * + * @param {Object} doc + * The toolbox document to query. + * @param {Object} menuButton + * The button whose size and popup size we should measure. + * @return {Object} + * An object with the following properties: + * - buttonBounds {DOMRect} Bounds of the button. + * - menuType {string} Type of the menu, "native" or "doorhanger". + * - menuBounds {DOMRect} Bounds of the menu panel. + * - arrowBounds {DOMRect|null} Bounds of the arrow. Only set when + * menuType is "doorhanger", null otherwise. + */ +async function getButtonAndMenuInfo(toolbox, menuButton) { + const { doc, topDoc } = toolbox; + info("Show popup menu with click event."); + AccessibilityUtils.setEnv({ + // Keyboard accessibility is handled on the toolbox toolbar container level. + // Users can use arrow keys to navigate between and select tabs. + nonNegativeTabIndexRule: false, + }); + EventUtils.sendMouseEvent( + { + type: "click", + screenX: 1, + }, + menuButton, + doc.defaultView + ); + AccessibilityUtils.resetEnv(); + + let menuPopup; + let menuType; + let menuBounds = null; + let arrowBounds = null; + if (menuButton.hasAttribute("aria-controls")) { + menuType = "doorhanger"; + menuPopup = doc.getElementById(menuButton.getAttribute("aria-controls")); + await waitUntil(() => menuPopup.classList.contains("tooltip-visible")); + // menuPopup can be a non-menupopup element, e.g. div. Call getBoxQuads to + // get its bounds. + menuBounds = menuPopup.getBoxQuads({ relativeTo: doc })[0].getBounds(); + } else { + menuType = "native"; + await waitUntil(() => { + const popupset = topDoc.querySelector("popupset"); + menuPopup = popupset?.querySelector('menupopup[menu-api="true"]'); + return menuPopup?.state === "open"; + }); + // menuPopup is a XUL menupopup element. Call getOuterScreenRect(), which is + // suported on both native and non-native menupopup implementations. + menuBounds = convertScreenToDoc(menuPopup, doc); + } + ok(menuPopup, "Menu popup is displayed."); + + const buttonBounds = menuButton + .getBoxQuads({ relativeTo: doc })[0] + .getBounds(); + + if (menuType === "doorhanger") { + const arrow = menuPopup.querySelector(".tooltip-arrow"); + arrowBounds = arrow.getBoxQuads({ relativeTo: doc })[0].getBounds(); + } + + info("Hide popup menu."); + if (menuType === "doorhanger") { + EventUtils.sendKey("Escape", doc.defaultView); + await waitUntil(() => !menuPopup.classList.contains("tooltip-visible")); + } else { + const popupHidden = once(menuPopup, "popuphidden"); + menuPopup.hidePopup(); + await popupHidden; + } + + return { buttonBounds, menuType, menuBounds, arrowBounds }; +} diff --git a/devtools/client/framework/test/browser_webextension_descriptor.js b/devtools/client/framework/test/browser_webextension_descriptor.js new file mode 100644 index 0000000000..c3d1392a31 --- /dev/null +++ b/devtools/client/framework/test/browser_webextension_descriptor.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function test_webextension_descriptors() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + name: "Descriptor extension", + }, + }); + + await extension.startup(); + + // Get AddonTarget. + const commands = await CommandsFactory.forAddon(extension.id); + const descriptor = commands.descriptorFront; + ok(descriptor, "webextension descriptor has been found"); + is(descriptor.name, "Descriptor extension", "Descriptor name is correct"); + is(descriptor.debuggable, true, "Descriptor debuggable attribute is correct"); + + const onDestroyed = descriptor.once("descriptor-destroyed"); + info("Uninstall the extension"); + await extension.unload(); + info("Wait for the descriptor to be destroyed"); + await onDestroyed; + + await commands.destroy(); +}); diff --git a/devtools/client/framework/test/browser_webextension_dropdown.js b/devtools/client/framework/test/browser_webextension_dropdown.js new file mode 100644 index 0000000000..b291ac7a9e --- /dev/null +++ b/devtools/client/framework/test/browser_webextension_dropdown.js @@ -0,0 +1,135 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* globals browser */ + +const URL = + "data:text/html;charset=utf8,test for drop down menu in devtools extension"; + +add_task(async function runTest() { + const extension = await startupExtension(); + + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + }); + const { + Toolbox, + } = require("resource://devtools/client/framework/toolbox.js"); + await toolbox.switchHost(Toolbox.HostType.WINDOW); + + await extension.awaitMessage("devtools_page_loaded"); + + const toolboxAdditionalTools = toolbox.getAdditionalTools(); + is( + toolboxAdditionalTools.length, + 1, + "Got the expected number of toolbox specific panel registered." + ); + + const panelId = toolboxAdditionalTools[0].id; + + await gDevTools.showToolboxForTab(tab, { toolId: panelId }); + + await extension.awaitMessage("devtools_panel_loaded"); + + const panel = findExtensionPanel(); + ok(panel, "found extension panel"); + + const iframe = panel.firstChild; + const popupShownPromise = BrowserTestUtils.waitForSelectPopupShown( + toolbox.win.browsingContext.topChromeWindow + ); + + const browser = iframe.contentDocument.getElementById( + "webext-panels-browser" + ); + ok(browser, "found extension panel browser"); + + info("Waiting for menu"); + await ContentTask.spawn(browser, null, async function () { + const menu = content.document.getElementById("menu"); + const event = new content.MouseEvent("mousedown"); + menu.dispatchEvent(event); + }); + + const popup = await popupShownPromise; + info("popup is shown"); + + popup.hidePopup(); + + await toolbox.destroy(); + + gBrowser.removeCurrentTab(); + + await extension.unload(); +}); + +async function startupExtension() { + async function devtools_page() { + await browser.devtools.panels.create( + "drop", + "/icon.png", + "/devtools_panel.html" + ); + browser.test.sendMessage("devtools_page_loaded"); + } + + async function devtools_panel() { + browser.test.sendMessage("devtools_panel_loaded"); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + devtools_page: "devtools_page.html", + }, + files: { + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="devtools_page.js"></script> + </body> + </html>`, + "devtools_page.js": devtools_page, + "icon.png": "", + "devtools_panel.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <select id="menu"> + <option value="A" selected>A</option> + <option value="B">B</option> + <option value="C">C</option> + </select> + <script src="devtools_panel.js"></script> + </body> + </html>`, + "devtools_panel.js": devtools_panel, + }, + }); + + await extension.startup(); + + return extension; +} + +function findExtensionPanel() { + const win = Services.wm.getMostRecentWindow("devtools:toolbox"); + ok(win, "toolbox separate window exists"); + + const iframe = win.document.querySelector(".devtools-toolbox-window-iframe"); + const deck = iframe.contentDocument.getElementById("toolbox-deck"); + for (const box of deck.childNodes) { + if (box.id && box.id.startsWith("toolbox-panel-webext-devtools-panel")) { + return box; + } + } + return null; +} diff --git a/devtools/client/framework/test/code_binary_search.coffee b/devtools/client/framework/test/code_binary_search.coffee new file mode 100644 index 0000000000..e3dacdaaab --- /dev/null +++ b/devtools/client/framework/test/code_binary_search.coffee @@ -0,0 +1,18 @@ +# Uses a binary search algorithm to locate a value in the specified array. +window.binary_search = (items, value) -> + + start = 0 + stop = items.length - 1 + pivot = Math.floor (start + stop) / 2 + + while items[pivot] isnt value and start < stop + + # Adjust the search area. + stop = pivot - 1 if value < items[pivot] + start = pivot + 1 if value > items[pivot] + + # Recalculate the pivot. + pivot = Math.floor (stop + start) / 2 + + # Make sure we've found the correct value. + if items[pivot] is value then pivot else -1
\ No newline at end of file diff --git a/devtools/client/framework/test/code_binary_search.js b/devtools/client/framework/test/code_binary_search.js new file mode 100644 index 0000000000..c43848a60c --- /dev/null +++ b/devtools/client/framework/test/code_binary_search.js @@ -0,0 +1,29 @@ +// Generated by CoffeeScript 1.6.1 +(function() { + + window.binary_search = function(items, value) { + var pivot, start, stop; + start = 0; + stop = items.length - 1; + pivot = Math.floor((start + stop) / 2); + while (items[pivot] !== value && start < stop) { + if (value < items[pivot]) { + stop = pivot - 1; + } + if (value > items[pivot]) { + start = pivot + 1; + } + pivot = Math.floor((stop + start) / 2); + } + if (items[pivot] === value) { + return pivot; + } else { + return -1; + } + }; + +}).call(this); + +/* +//# sourceMappingURL=code_binary_search.map +*/ diff --git a/devtools/client/framework/test/code_binary_search.map b/devtools/client/framework/test/code_binary_search.map new file mode 100644 index 0000000000..8d22511252 --- /dev/null +++ b/devtools/client/framework/test/code_binary_search.map @@ -0,0 +1,10 @@ +{ + "version": 3, + "file": "code_binary_search.js", + "sourceRoot": "", + "sources": [ + "code_binary_search.coffee" + ], + "names": [], + "mappings": ";AACA;CAAA;CAAA,CAAA,CAAuB,EAAA,CAAjB,GAAkB,IAAxB;CAEE,OAAA,UAAA;CAAA,EAAQ,CAAR,CAAA;CAAA,EACQ,CAAR,CAAa,CAAL;CADR,EAEQ,CAAR,CAAA;CAEA,EAA0C,CAAR,CAAtB,MAAN;CAGJ,EAA6B,CAAR,CAAA,CAArB;CAAA,EAAQ,CAAR,CAAQ,GAAR;QAAA;CACA,EAA6B,CAAR,CAAA,CAArB;CAAA,EAAQ,EAAR,GAAA;QADA;CAAA,EAIQ,CAAI,CAAZ,CAAA;CAXF,IAIA;CAUA,GAAA,CAAS;CAAT,YAA8B;MAA9B;AAA0C,CAAD,YAAA;MAhBpB;CAAvB,EAAuB;CAAvB" +} diff --git a/devtools/client/framework/test/code_binary_search_absolute.js b/devtools/client/framework/test/code_binary_search_absolute.js new file mode 100644 index 0000000000..7a529f3e88 --- /dev/null +++ b/devtools/client/framework/test/code_binary_search_absolute.js @@ -0,0 +1,29 @@ +// Generated by CoffeeScript 1.6.1 +(function() { + + window.binary_search = function(items, value) { + var pivot, start, stop; + start = 0; + stop = items.length - 1; + pivot = Math.floor((start + stop) / 2); + while (items[pivot] !== value && start < stop) { + if (value < items[pivot]) { + stop = pivot - 1; + } + if (value > items[pivot]) { + start = pivot + 1; + } + pivot = Math.floor((stop + start) / 2); + } + if (items[pivot] === value) { + return pivot; + } else { + return -1; + } + }; + +}).call(this); + +/* +//# sourceMappingURL=code_binary_search_absolute.map +*/ diff --git a/devtools/client/framework/test/code_binary_search_absolute.map b/devtools/client/framework/test/code_binary_search_absolute.map new file mode 100644 index 0000000000..04dd827940 --- /dev/null +++ b/devtools/client/framework/test/code_binary_search_absolute.map @@ -0,0 +1,10 @@ +{ + "version": 3, + "file": "code_binary_search.js", + "sourceRoot": "https://example.com/browser/devtools/client/framework/test/", + "sources": [ + "code_binary_search.coffee" + ], + "names": [], + "mappings": ";AACA;CAAA;CAAA,CAAA,CAAuB,EAAA,CAAjB,GAAkB,IAAxB;CAEE,OAAA,UAAA;CAAA,EAAQ,CAAR,CAAA;CAAA,EACQ,CAAR,CAAa,CAAL;CADR,EAEQ,CAAR,CAAA;CAEA,EAA0C,CAAR,CAAtB,MAAN;CAGJ,EAA6B,CAAR,CAAA,CAArB;CAAA,EAAQ,CAAR,CAAQ,GAAR;QAAA;CACA,EAA6B,CAAR,CAAA,CAArB;CAAA,EAAQ,EAAR,GAAA;QADA;CAAA,EAIQ,CAAI,CAAZ,CAAA;CAXF,IAIA;CAUA,GAAA,CAAS;CAAT,YAA8B;MAA9B;AAA0C,CAAD,YAAA;MAhBpB;CAAvB,EAAuB;CAAvB" +} diff --git a/devtools/client/framework/test/code_bundle_cross_domain.js b/devtools/client/framework/test/code_bundle_cross_domain.js new file mode 100644 index 0000000000..7b50467508 --- /dev/null +++ b/devtools/client/framework/test/code_bundle_cross_domain.js @@ -0,0 +1,93 @@ +/******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) { +/******/ return installedModules[moduleId].exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; +/******/ +/******/ // define getter function for harmony exports +/******/ __webpack_require__.d = function(exports, name, getter) { +/******/ if(!__webpack_require__.o(exports, name)) { +/******/ Object.defineProperty(exports, name, { +/******/ configurable: false, +/******/ enumerable: true, +/******/ get: getter +/******/ }); +/******/ } +/******/ }; +/******/ +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = function(module) { +/******/ var getter = module && module.__esModule ? +/******/ function getDefault() { return module['default']; } : +/******/ function getModuleExports() { return module; }; +/******/ __webpack_require__.d(getter, 'a', getter); +/******/ return getter; +/******/ }; +/******/ +/******/ // Object.prototype.hasOwnProperty.call +/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; +/******/ +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = ""; +/******/ +/******/ // Load entry module and return exports +/******/ return __webpack_require__(__webpack_require__.s = 0); +/******/ }) +/************************************************************************/ +/******/ ([ +/* 0 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Original source code for the cross-domain source map test. +// The generated file was made with +// webpack --devtool source-map code_cross_domain.js code_bundle_cross_domain.js +// ... and then edited to replace the generated sourceMappingURL. + + + +function f() { + console.log("anything will do"); +} + +f(); + +// Avoid script GC. +window.f = f; + + +/***/ }) +/******/ ]); +//# sourceMappingURL=http://test2.mochi.test:8888/browser/devtools/client/framework/test/code_bundle_cross_domain.js.map diff --git a/devtools/client/framework/test/code_bundle_cross_domain.js.map b/devtools/client/framework/test/code_bundle_cross_domain.js.map new file mode 100644 index 0000000000..59df6f6b41 --- /dev/null +++ b/devtools/client/framework/test/code_bundle_cross_domain.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["webpack:///webpack/bootstrap 7b928b82bd207211f478","webpack:///./code_cross_domain.js"],"names":[],"mappings":";AAAA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;;;AAGA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,aAAK;AACL;AACA;;AAEA;AACA;AACA;AACA,mCAA2B,0BAA0B,EAAE;AACvD,yCAAiC,eAAe;AAChD;AACA;AACA;;AAEA;AACA,8DAAsD,+DAA+D;;AAErH;AACA;;AAEA;AACA;;;;;;;;AC7DA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;;AAEA;AACA;AACA;;AAEA;;AAEA;AACA","file":"code_bundle_cross_domain.js","sourcesContent":[" \t// The module cache\n \tvar installedModules = {};\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, {\n \t\t\t\tconfigurable: false,\n \t\t\t\tenumerable: true,\n \t\t\t\tget: getter\n \t\t\t});\n \t\t}\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"\";\n\n \t// Load entry module and return exports\n \treturn __webpack_require__(__webpack_require__.s = 0);\n\n\n\n// WEBPACK FOOTER //\n// webpack/bootstrap 7b928b82bd207211f478","/* Any copyright is dedicated to the Public Domain.\n http://creativecommons.org/publicdomain/zero/1.0/ */\n\n// Original source code for the cross-domain source map test.\n// The generated file was made with\n// webpack --devtool source-map code_cross_domain.js code_bundle_cross_domain.js\n// ... and then edited to replace the generated sourceMappingURL.\n\n\"use strict\";\n\nfunction f() {\n console.log(\"anything will do\");\n}\n\nf();\n\n// Avoid script GC.\nwindow.f = f;\n\n\n\n//////////////////\n// WEBPACK FOOTER\n// ./code_cross_domain.js\n// module id = 0\n// module chunks = 0"],"sourceRoot":""}
\ No newline at end of file diff --git a/devtools/client/framework/test/code_bundle_late_script.js b/devtools/client/framework/test/code_bundle_late_script.js new file mode 100644 index 0000000000..3055d249bf --- /dev/null +++ b/devtools/client/framework/test/code_bundle_late_script.js @@ -0,0 +1,116 @@ +/******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) { +/******/ return installedModules[moduleId].exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; +/******/ +/******/ // define getter function for harmony exports +/******/ __webpack_require__.d = function(exports, name, getter) { +/******/ if(!__webpack_require__.o(exports, name)) { +/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); +/******/ } +/******/ }; +/******/ +/******/ // define __esModule on exports +/******/ __webpack_require__.r = function(exports) { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ +/******/ // create a fake namespace object +/******/ // mode & 1: value is a module id, require it +/******/ // mode & 2: merge all properties of value into the ns +/******/ // mode & 4: return value when already ns object +/******/ // mode & 8|1: behave like require +/******/ __webpack_require__.t = function(value, mode) { +/******/ if(mode & 1) value = __webpack_require__(value); +/******/ if(mode & 8) return value; +/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; +/******/ var ns = Object.create(null); +/******/ __webpack_require__.r(ns); +/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); +/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); +/******/ return ns; +/******/ }; +/******/ +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = function(module) { +/******/ var getter = module && module.__esModule ? +/******/ function getDefault() { return module['default']; } : +/******/ function getModuleExports() { return module; }; +/******/ __webpack_require__.d(getter, 'a', getter); +/******/ return getter; +/******/ }; +/******/ +/******/ // Object.prototype.hasOwnProperty.call +/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; +/******/ +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = ""; +/******/ +/******/ +/******/ // Load entry module and return exports +/******/ return __webpack_require__(__webpack_require__.s = "./code_late_script.js"); +/******/ }) +/************************************************************************/ +/******/ ({ + +/***/ "./code_late_script.js": +/*!*****************************!*\ + !*** ./code_late_script.js ***! + \*****************************/ +/*! no static exports found */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Original source code for the inline source map test. +// The generated file was made with +// webpack --devtool source-map code_late_script.js code_bundle_late_script.js + + + +function f() { + console.log("The first version of the script"); +} + +f(); + + +/***/ }) + +/******/ }); +//# sourceMappingURL=code_bundle_late_script.js.map
\ No newline at end of file diff --git a/devtools/client/framework/test/code_bundle_late_script.js.map b/devtools/client/framework/test/code_bundle_late_script.js.map new file mode 100644 index 0000000000..319fdadc51 --- /dev/null +++ b/devtools/client/framework/test/code_bundle_late_script.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["webpack:///webpack/bootstrap","webpack:///./code_late_script.js"],"names":[],"mappings":";AAAA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;;;AAGA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;AACA,kDAA0C,gCAAgC;AAC1E;AACA;;AAEA;AACA;AACA;AACA,gEAAwD,kBAAkB;AAC1E;AACA,yDAAiD,cAAc;AAC/D;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,iDAAyC,iCAAiC;AAC1E,wHAAgH,mBAAmB,EAAE;AACrI;AACA;;AAEA;AACA;AACA;AACA,mCAA2B,0BAA0B,EAAE;AACvD,yCAAiC,eAAe;AAChD;AACA;AACA;;AAEA;AACA,8DAAsD,+DAA+D;;AAErH;AACA;;;AAGA;AACA;;;;;;;;;;;;;AClFA;AACA;;AAEA;AACA;AACA;;AAEa;;AAEb;AACA;AACA;;AAEA","file":"code_bundle_late_script.js","sourcesContent":[" \t// The module cache\n \tvar installedModules = {};\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, { enumerable: true, get: getter });\n \t\t}\n \t};\n\n \t// define __esModule on exports\n \t__webpack_require__.r = function(exports) {\n \t\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n \t\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n \t\t}\n \t\tObject.defineProperty(exports, '__esModule', { value: true });\n \t};\n\n \t// create a fake namespace object\n \t// mode & 1: value is a module id, require it\n \t// mode & 2: merge all properties of value into the ns\n \t// mode & 4: return value when already ns object\n \t// mode & 8|1: behave like require\n \t__webpack_require__.t = function(value, mode) {\n \t\tif(mode & 1) value = __webpack_require__(value);\n \t\tif(mode & 8) return value;\n \t\tif((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;\n \t\tvar ns = Object.create(null);\n \t\t__webpack_require__.r(ns);\n \t\tObject.defineProperty(ns, 'default', { enumerable: true, value: value });\n \t\tif(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));\n \t\treturn ns;\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"\";\n\n\n \t// Load entry module and return exports\n \treturn __webpack_require__(__webpack_require__.s = \"./code_late_script.js\");\n","/* Any copyright is dedicated to the Public Domain.\n http://creativecommons.org/publicdomain/zero/1.0/ */\n\n// Original source code for the inline source map test.\n// The generated file was made with\n// webpack --devtool source-map code_late_script.js code_bundle_late_script.js\n\n\"use strict\";\n\nfunction f() {\n console.log(\"The first version of the script\");\n}\n\nf();\n"],"sourceRoot":""}
\ No newline at end of file diff --git a/devtools/client/framework/test/code_bundle_no_race.js b/devtools/client/framework/test/code_bundle_no_race.js new file mode 100644 index 0000000000..43ebc6e89e --- /dev/null +++ b/devtools/client/framework/test/code_bundle_no_race.js @@ -0,0 +1,95 @@ +/******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) { +/******/ return installedModules[moduleId].exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; +/******/ +/******/ // identity function for calling harmony imports with the correct context +/******/ __webpack_require__.i = function(value) { return value; }; +/******/ +/******/ // define getter function for harmony exports +/******/ __webpack_require__.d = function(exports, name, getter) { +/******/ if(!__webpack_require__.o(exports, name)) { +/******/ Object.defineProperty(exports, name, { +/******/ configurable: false, +/******/ enumerable: true, +/******/ get: getter +/******/ }); +/******/ } +/******/ }; +/******/ +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = function(module) { +/******/ var getter = module && module.__esModule ? +/******/ function getDefault() { return module['default']; } : +/******/ function getModuleExports() { return module; }; +/******/ __webpack_require__.d(getter, 'a', getter); +/******/ return getter; +/******/ }; +/******/ +/******/ // Object.prototype.hasOwnProperty.call +/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; +/******/ +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = ""; +/******/ +/******/ // Load entry module and return exports +/******/ return __webpack_require__(__webpack_require__.s = 0); +/******/ }) +/************************************************************************/ +/******/ ([ +/* 0 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Original source code for the inline source map test. +// The generated file was made with +// webpack --devtool source-map code_no_race.js code_bundle_no_race.js + + + +function f() { + console.log("anything will do"); +} + +f(); + +// Avoid script GC. +window.f = f; + + +/***/ }) +/******/ ]); +//# sourceMappingURL=code_bundle_no_race.js.map
\ No newline at end of file diff --git a/devtools/client/framework/test/code_bundle_no_race.js.map b/devtools/client/framework/test/code_bundle_no_race.js.map new file mode 100644 index 0000000000..df3f096283 --- /dev/null +++ b/devtools/client/framework/test/code_bundle_no_race.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["webpack:///webpack/bootstrap bac8dffc0cc5eb13fa9d","webpack:///./code_no_race.js"],"names":[],"mappings":";AAAA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;;AAEA;AACA;;AAEA;AACA;AACA;;;AAGA;AACA;;AAEA;AACA;;AAEA;AACA,mDAA2C,cAAc;;AAEzD;AACA;AACA;AACA;AACA;AACA;AACA;AACA,aAAK;AACL;AACA;;AAEA;AACA;AACA;AACA,mCAA2B,0BAA0B,EAAE;AACvD,yCAAiC,eAAe;AAChD;AACA;AACA;;AAEA;AACA,8DAAsD,+DAA+D;;AAErH;AACA;;AAEA;AACA;;;;;;;;AChEA;AACA;;AAEA;AACA;AACA;;AAEA;;AAEA;AACA;AACA;;AAEA;;AAEA;AACA","file":"code_bundle_no_race.js","sourcesContent":[" \t// The module cache\n \tvar installedModules = {};\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// identity function for calling harmony imports with the correct context\n \t__webpack_require__.i = function(value) { return value; };\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, {\n \t\t\t\tconfigurable: false,\n \t\t\t\tenumerable: true,\n \t\t\t\tget: getter\n \t\t\t});\n \t\t}\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"\";\n\n \t// Load entry module and return exports\n \treturn __webpack_require__(__webpack_require__.s = 0);\n\n\n\n// WEBPACK FOOTER //\n// webpack/bootstrap bac8dffc0cc5eb13fa9d","/* Any copyright is dedicated to the Public Domain.\n http://creativecommons.org/publicdomain/zero/1.0/ */\n\n// Original source code for the inline source map test.\n// The generated file was made with\n// webpack --devtool source-map code_no_race.js code_bundle_no_race.js\n\n\"use strict\";\n\nfunction f() {\n console.log(\"anything will do\");\n}\n\nf();\n\n// Avoid script GC.\nwindow.f = f;\n\n\n\n//////////////////\n// WEBPACK FOOTER\n// ./code_no_race.js\n// module id = 0\n// module chunks = 0"],"sourceRoot":""}
\ No newline at end of file diff --git a/devtools/client/framework/test/code_cross_domain.js b/devtools/client/framework/test/code_cross_domain.js new file mode 100644 index 0000000000..0e845c1466 --- /dev/null +++ b/devtools/client/framework/test/code_cross_domain.js @@ -0,0 +1,19 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Original source code for the cross-domain source map test. +// The generated file was made with +// webpack --devtool source-map code_cross_domain.js code_bundle_cross_domain.js +// ... and then the bundle was edited to replace the generated +// sourceMappingURL. + +"use strict"; + +function f() { + console.log("anything will do"); +} + +f(); + +// Avoid script GC. +window.f = f; diff --git a/devtools/client/framework/test/code_inline_bundle.js b/devtools/client/framework/test/code_inline_bundle.js new file mode 100644 index 0000000000..ff133a5376 --- /dev/null +++ b/devtools/client/framework/test/code_inline_bundle.js @@ -0,0 +1,92 @@ +/******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) { +/******/ return installedModules[moduleId].exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; +/******/ +/******/ // identity function for calling harmony imports with the correct context +/******/ __webpack_require__.i = function(value) { return value; }; +/******/ +/******/ // define getter function for harmony exports +/******/ __webpack_require__.d = function(exports, name, getter) { +/******/ if(!__webpack_require__.o(exports, name)) { +/******/ Object.defineProperty(exports, name, { +/******/ configurable: false, +/******/ enumerable: true, +/******/ get: getter +/******/ }); +/******/ } +/******/ }; +/******/ +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = function(module) { +/******/ var getter = module && module.__esModule ? +/******/ function getDefault() { return module['default']; } : +/******/ function getModuleExports() { return module; }; +/******/ __webpack_require__.d(getter, 'a', getter); +/******/ return getter; +/******/ }; +/******/ +/******/ // Object.prototype.hasOwnProperty.call +/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; +/******/ +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = ""; +/******/ +/******/ // Load entry module and return exports +/******/ return __webpack_require__(__webpack_require__.s = 0); +/******/ }) +/************************************************************************/ +/******/ ([ +/* 0 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Original source code for the inline source map test. +// The generated file was made with +// webpack --devtool inline-source-map code_inline_original.js code_inline_bundle.js + + + +function f() { + console.log("I'm a goldfish with a merry face"); +} + +f(); + + +/***/ }) +/******/ ]); +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vd2VicGFjay9ib290c3RyYXAgNDJlMDQyN2ExYTZlMzk3NTdjOGMiLCJ3ZWJwYWNrOi8vLy4vY29kZV9pbmxpbmVfb3JpZ2luYWwuanMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUFBO0FBQ0E7O0FBRUE7QUFDQTs7QUFFQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTs7QUFFQTtBQUNBOztBQUVBO0FBQ0E7O0FBRUE7QUFDQTtBQUNBOzs7QUFHQTtBQUNBOztBQUVBO0FBQ0E7O0FBRUE7QUFDQSxtREFBMkMsY0FBYzs7QUFFekQ7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQSxhQUFLO0FBQ0w7QUFDQTs7QUFFQTtBQUNBO0FBQ0E7QUFDQSxtQ0FBMkIsMEJBQTBCLEVBQUU7QUFDdkQseUNBQWlDLGVBQWU7QUFDaEQ7QUFDQTtBQUNBOztBQUVBO0FBQ0EsOERBQXNELCtEQUErRDs7QUFFckg7QUFDQTs7QUFFQTtBQUNBOzs7Ozs7OztBQ2hFQTtBQUNBOztBQUVBO0FBQ0E7QUFDQTs7QUFFQTs7QUFFQTtBQUNBO0FBQ0E7O0FBRUEiLCJmaWxlIjoiY29kZV9pbmxpbmVfYnVuZGxlLmpzIiwic291cmNlc0NvbnRlbnQiOlsiIFx0Ly8gVGhlIG1vZHVsZSBjYWNoZVxuIFx0dmFyIGluc3RhbGxlZE1vZHVsZXMgPSB7fTtcblxuIFx0Ly8gVGhlIHJlcXVpcmUgZnVuY3Rpb25cbiBcdGZ1bmN0aW9uIF9fd2VicGFja19yZXF1aXJlX18obW9kdWxlSWQpIHtcblxuIFx0XHQvLyBDaGVjayBpZiBtb2R1bGUgaXMgaW4gY2FjaGVcbiBcdFx0aWYoaW5zdGFsbGVkTW9kdWxlc1ttb2R1bGVJZF0pIHtcbiBcdFx0XHRyZXR1cm4gaW5zdGFsbGVkTW9kdWxlc1ttb2R1bGVJZF0uZXhwb3J0cztcbiBcdFx0fVxuIFx0XHQvLyBDcmVhdGUgYSBuZXcgbW9kdWxlIChhbmQgcHV0IGl0IGludG8gdGhlIGNhY2hlKVxuIFx0XHR2YXIgbW9kdWxlID0gaW5zdGFsbGVkTW9kdWxlc1ttb2R1bGVJZF0gPSB7XG4gXHRcdFx0aTogbW9kdWxlSWQsXG4gXHRcdFx0bDogZmFsc2UsXG4gXHRcdFx0ZXhwb3J0czoge31cbiBcdFx0fTtcblxuIFx0XHQvLyBFeGVjdXRlIHRoZSBtb2R1bGUgZnVuY3Rpb25cbiBcdFx0bW9kdWxlc1ttb2R1bGVJZF0uY2FsbChtb2R1bGUuZXhwb3J0cywgbW9kdWxlLCBtb2R1bGUuZXhwb3J0cywgX193ZWJwYWNrX3JlcXVpcmVfXyk7XG5cbiBcdFx0Ly8gRmxhZyB0aGUgbW9kdWxlIGFzIGxvYWRlZFxuIFx0XHRtb2R1bGUubCA9IHRydWU7XG5cbiBcdFx0Ly8gUmV0dXJuIHRoZSBleHBvcnRzIG9mIHRoZSBtb2R1bGVcbiBcdFx0cmV0dXJuIG1vZHVsZS5leHBvcnRzO1xuIFx0fVxuXG5cbiBcdC8vIGV4cG9zZSB0aGUgbW9kdWxlcyBvYmplY3QgKF9fd2VicGFja19tb2R1bGVzX18pXG4gXHRfX3dlYnBhY2tfcmVxdWlyZV9fLm0gPSBtb2R1bGVzO1xuXG4gXHQvLyBleHBvc2UgdGhlIG1vZHVsZSBjYWNoZVxuIFx0X193ZWJwYWNrX3JlcXVpcmVfXy5jID0gaW5zdGFsbGVkTW9kdWxlcztcblxuIFx0Ly8gaWRlbnRpdHkgZnVuY3Rpb24gZm9yIGNhbGxpbmcgaGFybW9ueSBpbXBvcnRzIHdpdGggdGhlIGNvcnJlY3QgY29udGV4dFxuIFx0X193ZWJwYWNrX3JlcXVpcmVfXy5pID0gZnVuY3Rpb24odmFsdWUpIHsgcmV0dXJuIHZhbHVlOyB9O1xuXG4gXHQvLyBkZWZpbmUgZ2V0dGVyIGZ1bmN0aW9uIGZvciBoYXJtb255IGV4cG9ydHNcbiBcdF9fd2VicGFja19yZXF1aXJlX18uZCA9IGZ1bmN0aW9uKGV4cG9ydHMsIG5hbWUsIGdldHRlcikge1xuIFx0XHRpZighX193ZWJwYWNrX3JlcXVpcmVfXy5vKGV4cG9ydHMsIG5hbWUpKSB7XG4gXHRcdFx0T2JqZWN0LmRlZmluZVByb3BlcnR5KGV4cG9ydHMsIG5hbWUsIHtcbiBcdFx0XHRcdGNvbmZpZ3VyYWJsZTogZmFsc2UsXG4gXHRcdFx0XHRlbnVtZXJhYmxlOiB0cnVlLFxuIFx0XHRcdFx0Z2V0OiBnZXR0ZXJcbiBcdFx0XHR9KTtcbiBcdFx0fVxuIFx0fTtcblxuIFx0Ly8gZ2V0RGVmYXVsdEV4cG9ydCBmdW5jdGlvbiBmb3IgY29tcGF0aWJpbGl0eSB3aXRoIG5vbi1oYXJtb255IG1vZHVsZXNcbiBcdF9fd2VicGFja19yZXF1aXJlX18ubiA9IGZ1bmN0aW9uKG1vZHVsZSkge1xuIFx0XHR2YXIgZ2V0dGVyID0gbW9kdWxlICYmIG1vZHVsZS5fX2VzTW9kdWxlID9cbiBcdFx0XHRmdW5jdGlvbiBnZXREZWZhdWx0KCkgeyByZXR1cm4gbW9kdWxlWydkZWZhdWx0J107IH0gOlxuIFx0XHRcdGZ1bmN0aW9uIGdldE1vZHVsZUV4cG9ydHMoKSB7IHJldHVybiBtb2R1bGU7IH07XG4gXHRcdF9fd2VicGFja19yZXF1aXJlX18uZChnZXR0ZXIsICdhJywgZ2V0dGVyKTtcbiBcdFx0cmV0dXJuIGdldHRlcjtcbiBcdH07XG5cbiBcdC8vIE9iamVjdC5wcm90b3R5cGUuaGFzT3duUHJvcGVydHkuY2FsbFxuIFx0X193ZWJwYWNrX3JlcXVpcmVfXy5vID0gZnVuY3Rpb24ob2JqZWN0LCBwcm9wZXJ0eSkgeyByZXR1cm4gT2JqZWN0LnByb3RvdHlwZS5oYXNPd25Qcm9wZXJ0eS5jYWxsKG9iamVjdCwgcHJvcGVydHkpOyB9O1xuXG4gXHQvLyBfX3dlYnBhY2tfcHVibGljX3BhdGhfX1xuIFx0X193ZWJwYWNrX3JlcXVpcmVfXy5wID0gXCJcIjtcblxuIFx0Ly8gTG9hZCBlbnRyeSBtb2R1bGUgYW5kIHJldHVybiBleHBvcnRzXG4gXHRyZXR1cm4gX193ZWJwYWNrX3JlcXVpcmVfXyhfX3dlYnBhY2tfcmVxdWlyZV9fLnMgPSAwKTtcblxuXG5cbi8vIFdFQlBBQ0sgRk9PVEVSIC8vXG4vLyB3ZWJwYWNrL2Jvb3RzdHJhcCA0MmUwNDI3YTFhNmUzOTc1N2M4YyIsIi8qIEFueSBjb3B5cmlnaHQgaXMgZGVkaWNhdGVkIHRvIHRoZSBQdWJsaWMgRG9tYWluLlxuIGh0dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL3B1YmxpY2RvbWFpbi96ZXJvLzEuMC8gKi9cblxuLy8gT3JpZ2luYWwgc291cmNlIGNvZGUgZm9yIHRoZSBpbmxpbmUgc291cmNlIG1hcCB0ZXN0LlxuLy8gVGhlIGdlbmVyYXRlZCBmaWxlIHdhcyBtYWRlIHdpdGhcbi8vICAgIHdlYnBhY2sgLS1kZXZ0b29sIGlubGluZS1zb3VyY2UtbWFwIGNvZGVfaW5saW5lX29yaWdpbmFsLmpzIGNvZGVfaW5saW5lX2J1bmRsZS5qc1xuXG5cInVzZSBzdHJpY3RcIjtcblxuZnVuY3Rpb24gZigpIHtcbiAgY29uc29sZS5sb2coXCJJJ20gYSBnb2xkZmlzaCB3aXRoIGEgbWVycnkgZmFjZVwiKTtcbn1cblxuZigpO1xuXG5cblxuLy8vLy8vLy8vLy8vLy8vLy8vXG4vLyBXRUJQQUNLIEZPT1RFUlxuLy8gLi9jb2RlX2lubGluZV9vcmlnaW5hbC5qc1xuLy8gbW9kdWxlIGlkID0gMFxuLy8gbW9kdWxlIGNodW5rcyA9IDAiXSwic291cmNlUm9vdCI6IiJ9
\ No newline at end of file diff --git a/devtools/client/framework/test/code_inline_original.js b/devtools/client/framework/test/code_inline_original.js new file mode 100644 index 0000000000..c1b0b033cd --- /dev/null +++ b/devtools/client/framework/test/code_inline_original.js @@ -0,0 +1,14 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Original source code for the inline source map test. +// The generated file was made with +// webpack --devtool inline-source-map code_inline_original.js code_inline_bundle.js + +"use strict"; + +function f() { + console.log("I'm a goldfish with a merry face"); +} + +f(); diff --git a/devtools/client/framework/test/code_late_script.js b/devtools/client/framework/test/code_late_script.js new file mode 100644 index 0000000000..a9ed62dba9 --- /dev/null +++ b/devtools/client/framework/test/code_late_script.js @@ -0,0 +1,14 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Original source code for the inline source map test. +// The generated file was made with +// webpack --devtool source-map code_late_script.js --output code_bundle_late_script.js --mode development + +"use strict"; + +function f() { + console.log("The first version of the script"); +} + +f(); diff --git a/devtools/client/framework/test/code_math.js b/devtools/client/framework/test/code_math.js new file mode 100644 index 0000000000..0aace9b59f --- /dev/null +++ b/devtools/client/framework/test/code_math.js @@ -0,0 +1,7 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function add(a, b, k) { + var result = a + b; + return k(result); +} diff --git a/devtools/client/framework/test/code_no_race.js b/devtools/client/framework/test/code_no_race.js new file mode 100644 index 0000000000..3c7fd72efd --- /dev/null +++ b/devtools/client/framework/test/code_no_race.js @@ -0,0 +1,17 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Original source code for the inline source map test. +// The generated file was made with +// webpack --devtool source-map code_no_race.js code_bundle_no_race.js + +"use strict"; + +function f() { + console.log("anything will do"); +} + +f(); + +// Avoid script GC. +window.f = f; diff --git a/devtools/client/framework/test/doc_backward_forward_navigation.html b/devtools/client/framework/test/doc_backward_forward_navigation.html new file mode 100644 index 0000000000..52eb65e00b --- /dev/null +++ b/devtools/client/framework/test/doc_backward_forward_navigation.html @@ -0,0 +1,40 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> +<html> + <head> + <meta charset="utf-8"/> + <title>Test backward/forward navigation</title> + </head> + <body> + <ul class="logs"></ul> + <script> + const query = new URLSearchParams(document.location.search); + const noMutation = query.has("no-mutation"); + + /* Add stylesheet, script and dom nodes so it triggers multiple actions in the toolbox. */ + function addContent() { + const now = Date.now(); + + const styleSheetEl = document.createElement("link"); + styleSheetEl.href = "./doc_theme.css?id=" + now; + document.head.append(styleSheetEl); + + const scriptEl = document.createElement("script"); + scriptEl.src = "./code_inline_bundle.js?id=" + now; + document.body.append(scriptEl); + + const li = document.createElement("li"); + li.textContent = now; + document.querySelector("ul.logs").append(li); + } + + if (noMutation) { + document.body.classList.add("no-mutation"); + addContent(); + } else { + setInterval(addContent, 200); + } + </script> + </body> +</html> diff --git a/devtools/client/framework/test/doc_cached-resource.html b/devtools/client/framework/test/doc_cached-resource.html new file mode 100644 index 0000000000..2f1cc415c6 --- /dev/null +++ b/devtools/client/framework/test/doc_cached-resource.html @@ -0,0 +1,15 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> + +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + </head> + <body> + <iframe src="https://example.org/browser/devtools/client/framework/test/doc_cached-resource_iframe.html"></iframe> + <script> + console.log("Hello from parent"); + </script> + </body> +</html> diff --git a/devtools/client/framework/test/doc_cached-resource_iframe.html b/devtools/client/framework/test/doc_cached-resource_iframe.html new file mode 100644 index 0000000000..0fc5bb2263 --- /dev/null +++ b/devtools/client/framework/test/doc_cached-resource_iframe.html @@ -0,0 +1,14 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> + +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + </head> + <body> + <script> + console.log("Hello from child"); + </script> + </body> +</html> diff --git a/devtools/client/framework/test/doc_empty-tab-01.html b/devtools/client/framework/test/doc_empty-tab-01.html new file mode 100644 index 0000000000..28398f7768 --- /dev/null +++ b/devtools/client/framework/test/doc_empty-tab-01.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>Empty test page 1</title> + </head> + + <body> + </body> + +</html> diff --git a/devtools/client/framework/test/doc_lazy_tool.html b/devtools/client/framework/test/doc_lazy_tool.html new file mode 100644 index 0000000000..3f1f1b7d01 --- /dev/null +++ b/devtools/client/framework/test/doc_lazy_tool.html @@ -0,0 +1,6 @@ +<!DOCTYPE html> +<html> +<body> + Lazy tool +</body> +</html> diff --git a/devtools/client/framework/test/doc_textbox_tool.html b/devtools/client/framework/test/doc_textbox_tool.html new file mode 100644 index 0000000000..6f0c32ade0 --- /dev/null +++ b/devtools/client/framework/test/doc_textbox_tool.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> +<body> + <input /> + <input type='text' /> + <input type='search' /> + <textarea></textarea> + <input type='radio' /> +</body> +</html> diff --git a/devtools/client/framework/test/doc_theme.css b/devtools/client/framework/test/doc_theme.css new file mode 100644 index 0000000000..5ed6e866a0 --- /dev/null +++ b/devtools/client/framework/test/doc_theme.css @@ -0,0 +1,3 @@ +.theme-test #devtools-theme-box { + color: red !important; +} diff --git a/devtools/client/framework/test/doc_viewsource.html b/devtools/client/framework/test/doc_viewsource.html new file mode 100644 index 0000000000..7094eb87eb --- /dev/null +++ b/devtools/client/framework/test/doc_viewsource.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="UTF-8"> + <title>Toolbox test for View Source methods</title> + <!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> + <link charset="UTF-8" rel="stylesheet" href="doc_theme.css" /> + <script src="code_math.js"></script> + </head> + <body> + </body> +</html> diff --git a/devtools/client/framework/test/head.js b/devtools/client/framework/test/head.js new file mode 100644 index 0000000000..2001d5e8c4 --- /dev/null +++ b/devtools/client/framework/test/head.js @@ -0,0 +1,490 @@ +/* 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/inspector/test/shared-head.js", + this +); + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +/** + * Retrieve all tool ids compatible with a target created for the provided tab. + * + * @param {XULTab} tab + * The tab for which we want to get the list of supported toolIds + * @return {Array<String>} array of tool ids + */ +async function getSupportedToolIds(tab) { + info("Getting the entire list of tools supported in this tab"); + + let shouldDestroyToolbox = false; + + // Get the toolbox for this tab, or create one if needed. + let toolbox = gDevTools.getToolboxForTab(tab); + if (!toolbox) { + toolbox = await gDevTools.showToolboxForTab(tab); + shouldDestroyToolbox = true; + } + + const toolIds = gDevTools + .getToolDefinitionArray() + .filter(def => def.isToolSupported(toolbox)) + .map(def => def.id); + + if (shouldDestroyToolbox) { + // Only close the toolbox if it was explicitly created here. + await toolbox.destroy(); + } + + return toolIds; +} + +function toggleAllTools(state) { + for (const [, tool] of gDevTools._tools) { + if (!tool.visibilityswitch) { + continue; + } + if (state) { + Services.prefs.setBoolPref(tool.visibilityswitch, true); + } else { + Services.prefs.clearUserPref(tool.visibilityswitch); + } + } +} + +async function getParentProcessActors(callback) { + const commands = await CommandsFactory.forMainProcess(); + const mainProcessTargetFront = await commands.descriptorFront.getTarget(); + + callback(commands.client, mainProcessTargetFront); +} + +function getSourceActor(aSources, aURL) { + const item = aSources.getItemForAttachment(a => a.source.url === aURL); + return item && item.value; +} + +/** + * Synthesize a keypress from a <key> element, taking into account + * any modifiers. + * @param {Element} el the <key> element to synthesize + */ +function synthesizeKeyElement(el) { + const key = el.getAttribute("key") || el.getAttribute("keycode"); + const mod = {}; + el.getAttribute("modifiers") + .split(" ") + .forEach(m => (mod[m + "Key"] = true)); + info(`Synthesizing: key=${key}, mod=${JSON.stringify(mod)}`); + EventUtils.synthesizeKey(key, mod, el.ownerDocument.defaultView); +} + +/* Check the toolbox host type and prefs to make sure they match the + * expected values + * @param {Toolbox} + * @param {HostType} hostType + * One of {SIDE, BOTTOM, WINDOW} from Toolbox.HostType + * @param {HostType} Optional previousHostType + * The host that will be switched to when calling switchToPreviousHost + */ +function checkHostType(toolbox, hostType, previousHostType) { + is(toolbox.hostType, hostType, "host type is " + hostType); + + const pref = Services.prefs.getCharPref("devtools.toolbox.host"); + is(pref, hostType, "host pref is " + hostType); + + if (previousHostType) { + is( + Services.prefs.getCharPref("devtools.toolbox.previousHost"), + previousHostType, + "The previous host is correct" + ); + } +} + +/** + * Create a new <script> referencing URL. Return a promise that + * resolves when this has happened + * @param {String} url + * the url + * @return {Promise} a promise that resolves when the element has been created + */ +function createScript(url) { + info(`Creating script: ${url}`); + // This is not ideal if called multiple times, as it loads the frame script + // separately each time. See bug 1443680. + return SpecialPowers.spawn(gBrowser.selectedBrowser, [url], urlChild => { + const script = content.document.createElement("script"); + script.setAttribute("src", urlChild); + content.document.body.appendChild(script); + }); +} + +/** + * Wait for the toolbox to notice that a given source is loaded + * @param {Toolbox} toolbox + * @param {String} url + * the url to wait for + * @return {Promise} a promise that is resolved when the source is loaded + */ +function waitForSourceLoad(toolbox, url) { + info(`Waiting for source ${url} to be available...`); + return new Promise(resolve => { + const { resourceCommand } = toolbox; + + function onAvailable(sources) { + for (const source of sources) { + if (source.url === url) { + resourceCommand.unwatchResources([resourceCommand.TYPES.SOURCE], { + onAvailable, + }); + resolve(); + } + } + } + resourceCommand.watchResources([resourceCommand.TYPES.SOURCE], { + onAvailable, + // Ignore the cached resources as we always listen *before* + // the action creating a source. + ignoreExistingResources: true, + }); + }); +} + +/** + * When a Toolbox is started it creates a DevToolPanel for each of the tools + * by calling toolDefinition.build(). The returned object should + * at least implement these functions. They will be used by the ToolBox. + * + * There may be no benefit in doing this as an abstract type, but if nothing + * else gives us a place to write documentation. + */ +function DevToolPanel(iframeWindow, toolbox) { + EventEmitter.decorate(this); + + this._toolbox = toolbox; + this._window = iframeWindow; +} + +DevToolPanel.prototype = { + open() { + return new Promise(resolve => { + executeSoon(() => { + resolve(this); + }); + }); + }, + + get document() { + return this._window.document; + }, + + get target() { + return this._toolbox.target; + }, + + get toolbox() { + return this._toolbox; + }, + + destroy() { + return Promise.resolve(null); + }, +}; + +/** + * Create a simple devtools test panel that implements the minimum API needed to be + * registered and opened in the toolbox. + */ +function createTestPanel(iframeWindow, toolbox) { + return new DevToolPanel(iframeWindow, toolbox); +} + +async function openChevronMenu(toolbox) { + const chevronMenuButton = toolbox.doc.querySelector(".tools-chevron-menu"); + EventUtils.synthesizeMouseAtCenter(chevronMenuButton, {}, toolbox.win); + + const menuPopup = toolbox.doc.getElementById( + "tools-chevron-menu-button-panel" + ); + ok(menuPopup, "tools-chevron-menupopup is available"); + + info("Waiting for the menu popup to be displayed"); + await waitUntil(() => menuPopup.classList.contains("tooltip-visible")); +} + +async function closeChevronMenu(toolbox) { + // In order to close the popup menu with escape key, set the focus to the chevron + // button at first. + const chevronMenuButton = toolbox.doc.querySelector(".tools-chevron-menu"); + chevronMenuButton.focus(); + + EventUtils.sendKey("ESCAPE", toolbox.doc.defaultView); + const menuPopup = toolbox.doc.getElementById( + "tools-chevron-menu-button-panel" + ); + + info("Closing the chevron popup menu"); + await waitUntil(() => !menuPopup.classList.contains("tooltip-visible")); +} + +function prepareToolTabReorderTest(toolbox, startingOrder) { + Services.prefs.setCharPref( + "devtools.toolbox.tabsOrder", + startingOrder.join(",") + ); + ok( + !toolbox.doc.getElementById("tools-chevron-menu-button"), + "The size of the screen being too small" + ); + + for (const id of startingOrder) { + ok(getElementByToolId(toolbox, id), `Tab element should exist for ${id}`); + } +} + +async function dndToolTab(toolbox, dragTarget, dropTarget, passedTargets = []) { + info(`Drag ${dragTarget} to ${dropTarget}`); + const dragTargetEl = getElementByToolIdOrExtensionIdOrSelector( + toolbox, + dragTarget + ); + + const onReady = dragTargetEl.classList.contains("selected") + ? Promise.resolve() + : toolbox.once("select"); + EventUtils.synthesizeMouseAtCenter( + dragTargetEl, + { type: "mousedown" }, + dragTargetEl.ownerGlobal + ); + await onReady; + + for (const passedTarget of passedTargets) { + info(`Via ${passedTarget}`); + const passedTargetEl = getElementByToolIdOrExtensionIdOrSelector( + toolbox, + passedTarget + ); + EventUtils.synthesizeMouseAtCenter( + passedTargetEl, + { type: "mousemove" }, + passedTargetEl.ownerGlobal + ); + } + + if (dropTarget) { + const dropTargetEl = getElementByToolIdOrExtensionIdOrSelector( + toolbox, + dropTarget + ); + EventUtils.synthesizeMouseAtCenter( + dropTargetEl, + { type: "mousemove" }, + dropTargetEl.ownerGlobal + ); + EventUtils.synthesizeMouseAtCenter( + dropTargetEl, + { type: "mouseup" }, + dropTargetEl.ownerGlobal + ); + } else { + const containerEl = toolbox.doc.getElementById("toolbox-container"); + EventUtils.synthesizeMouse( + containerEl, + 0, + 0, + { type: "mouseout" }, + containerEl.ownerGlobal + ); + } + + // Wait for updating the preference. + await new Promise(resolve => { + const onUpdated = () => { + Services.prefs.removeObserver("devtools.toolbox.tabsOrder", onUpdated); + resolve(); + }; + + Services.prefs.addObserver("devtools.toolbox.tabsOrder", onUpdated); + }); +} + +function assertToolTabOrder(toolbox, expectedOrder) { + info("Check the order of the tabs on the toolbar"); + + const tabEls = toolbox.doc.querySelectorAll(".devtools-tab"); + + for (let i = 0; i < expectedOrder.length; i++) { + const isOrdered = + tabEls[i].dataset.id === expectedOrder[i] || + tabEls[i].dataset.extensionId === expectedOrder[i]; + ok(isOrdered, `The tab[${expectedOrder[i]}] should exist at [${i}]`); + } +} + +function assertToolTabSelected(toolbox, dragTarget) { + info("Check whether the drag target was selected"); + const dragTargetEl = getElementByToolIdOrExtensionIdOrSelector( + toolbox, + dragTarget + ); + ok( + dragTargetEl.classList.contains("selected"), + "The dragged tool should be selected" + ); +} + +function assertToolTabPreferenceOrder(expectedOrder) { + info("Check the order in DevTools preference for tabs order"); + is( + Services.prefs.getCharPref("devtools.toolbox.tabsOrder"), + expectedOrder.join(","), + "The preference should be correct" + ); +} + +function getElementByToolId(toolbox, id) { + for (const tabEl of toolbox.doc.querySelectorAll(".devtools-tab")) { + if (tabEl.dataset.id === id || tabEl.dataset.extensionId === id) { + return tabEl; + } + } + + return null; +} + +function getElementByToolIdOrExtensionIdOrSelector(toolbox, idOrSelector) { + const tabEl = getElementByToolId(toolbox, idOrSelector); + return tabEl ? tabEl : toolbox.doc.querySelector(idOrSelector); +} + +/** + * Returns a toolbox tab element, even if it's overflowed + **/ +function getToolboxTab(doc, toolId) { + return ( + doc.getElementById(`toolbox-tab-${toolId}`) || + doc.getElementById(`tools-chevron-menupopup-${toolId}`) + ); +} + +function getWindow(toolbox) { + return toolbox.topWindow; +} + +async function resizeWindow(toolbox, width, height) { + const hostWindow = toolbox.win.parent; + const originalWidth = hostWindow.outerWidth; + const originalHeight = hostWindow.outerHeight; + const toWidth = width || originalWidth; + const toHeight = height || originalHeight; + + const onResize = once(hostWindow, "resize"); + hostWindow.resizeTo(toWidth, toHeight); + await onResize; +} + +function assertSelectedLocationInDebugger(debuggerPanel, line, column) { + const location = debuggerPanel._selectors.getSelectedLocation( + debuggerPanel._getState() + ); + is(location.line, line); + is(location.column, column); +} + +/** + * Open a new tab on about:devtools-toolbox with the provided params object used as + * queryString. + */ +async function openAboutToolbox(params) { + info("Open about:devtools-toolbox"); + const querystring = new URLSearchParams(); + Object.keys(params).forEach(x => querystring.append(x, params[x])); + + const tab = await addTab(`about:devtools-toolbox?${querystring}`); + const browser = tab.linkedBrowser; + + return { + tab, + document: browser.contentDocument, + }; +} + +/** + * Load FTL. + * + * @param {Toolbox} toolbox + * Toolbox instance. + * @param {String} path + * Path to the FTL file. + */ +function loadFTL(toolbox, path) { + const win = toolbox.doc.ownerGlobal; + + if (win.MozXULElement) { + win.MozXULElement.insertFTLIfNeeded(path); + } +} + +/** + * Emit a reload key shortcut from a given toolbox, and wait for the reload to + * be completed. + * + * @param {String} shortcut + * The key shortcut to send, as expected by the devtools shortcuts + * helpers (eg. "CmdOrCtrl+F5"). + * @param {Toolbox} toolbox + * The toolbox through which the event should be emitted. + */ +async function sendToolboxReloadShortcut(shortcut, toolbox) { + const promises = []; + + // If we have a jsdebugger panel, wait for it to complete its reload. + const jsdebugger = toolbox.getPanel("jsdebugger"); + if (jsdebugger) { + promises.push(jsdebugger.once("reloaded")); + } + + // If we have an inspector panel, wait for it to complete its reload. + const inspector = toolbox.getPanel("inspector"); + if (inspector) { + promises.push( + inspector.once("reloaded"), + inspector.once("inspector-updated") + ); + } + + const loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + promises.push(loadPromise); + + info("Focus the toolbox window and emit the reload shortcut: " + shortcut); + toolbox.win.focus(); + synthesizeKeyShortcut(shortcut, toolbox.win); + + info("Wait for page and toolbox reload promises"); + await Promise.all(promises); +} + +function getErrorIcon(toolbox) { + return toolbox.doc.querySelector(".toolbox-error"); +} + +function getErrorIconCount(toolbox) { + const textContent = getErrorIcon(toolbox)?.textContent; + try { + const int = parseInt(textContent, 10); + // 99+ parses to 99, so we check if the parsedInt does not match the textContent. + return int.toString() === textContent ? int : textContent; + } catch (e) { + // In case the parseInt threw, return the actual textContent so the test can display + // an easy to debug failure. + return textContent; + } +} diff --git a/devtools/client/framework/test/helper_disable_cache.js b/devtools/client/framework/test/helper_disable_cache.js new file mode 100644 index 0000000000..a98a68cc9f --- /dev/null +++ b/devtools/client/framework/test/helper_disable_cache.js @@ -0,0 +1,144 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This file assumes we have head.js globals for the scope where this is loaded. +/* import-globals-from head.js */ + +/* exported initTab, checkCacheStateForAllTabs, setDisableCacheCheckboxChecked, + finishUp */ + +// Common code shared by browser_toolbox_options_disable_cache-*.js +const TEST_URI = URL_ROOT + "browser_toolbox_options_disable_cache.sjs"; +var tabs = [ + { + title: "Tab 0", + desc: "Toggles cache on.", + startToolbox: true, + }, + { + title: "Tab 1", + desc: "Toolbox open before Tab 1 toggles cache.", + startToolbox: true, + }, + { + title: "Tab 2", + desc: "Opens toolbox after Tab 1 has toggled cache. Also closes and opens.", + startToolbox: false, + }, + { + title: "Tab 3", + desc: "No toolbox", + startToolbox: false, + }, +]; + +async function initTab(tabX, startToolbox) { + tabX.tab = await addTab(TEST_URI); + + if (startToolbox) { + tabX.toolbox = await gDevTools.showToolboxForTab(tabX.tab, { + toolId: "options", + }); + } +} + +async function checkCacheStateForAllTabs(states) { + for (let i = 0; i < tabs.length; i++) { + const tab = tabs[i]; + await checkCacheEnabled(tab, states[i]); + } +} + +async function checkCacheEnabled(tabX, expected) { + gBrowser.selectedTab = tabX.tab; + + await reloadTab(tabX); + + const oldGuid = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function () { + const doc = content.document; + const h1 = doc.querySelector("h1"); + return h1.textContent; + } + ); + + await reloadTab(tabX); + + const guid = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function () { + const doc = content.document; + const h1 = doc.querySelector("h1"); + return h1.textContent; + } + ); + + if (expected) { + is(guid, oldGuid, tabX.title + " cache is enabled"); + } else { + isnot(guid, oldGuid, tabX.title + " cache is not enabled"); + } +} + +async function setDisableCacheCheckboxChecked(tabX, state) { + gBrowser.selectedTab = tabX.tab; + + const panel = tabX.toolbox.getCurrentPanel(); + const cbx = panel.panelDoc.getElementById("devtools-disable-cache"); + + if (cbx.checked !== state) { + info("Setting disable cache checkbox to " + state + " for " + tabX.title); + const onReconfigured = tabX.toolbox.once("cache-reconfigured"); + cbx.click(); + + // We have to wait for the reconfigure request to be finished before reloading + // the page. + await onReconfigured; + } +} + +function reloadTab(tabX) { + const browser = gBrowser.selectedBrowser; + + const reloadTabPromise = BrowserTestUtils.browserLoaded(browser).then( + function () { + info("Reloaded tab " + tabX.title); + } + ); + + info("Reloading tab " + tabX.title); + SpecialPowers.spawn(browser, [], () => { + content.location.reload(false); + }); + + return reloadTabPromise; +} + +async function destroyTab(tabX) { + const toolbox = gDevTools.getToolboxForTab(tabX.tab); + + let onceDestroyed; + if (toolbox) { + onceDestroyed = gDevTools.once("toolbox-destroyed"); + } + + info("Removing tab " + tabX.title); + gBrowser.removeTab(tabX.tab); + info("Removed tab " + tabX.title); + + info("Waiting for toolbox-destroyed"); + await onceDestroyed; +} + +async function finishUp() { + for (const tab of tabs) { + await destroyTab(tab); + } + + tabs = null; +} diff --git a/devtools/client/framework/test/metrics/browser_metrics.toml b/devtools/client/framework/test/metrics/browser_metrics.toml new file mode 100644 index 0000000000..3e95c00dfe --- /dev/null +++ b/devtools/client/framework/test/metrics/browser_metrics.toml @@ -0,0 +1,19 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "head.js", + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", +] + +# Tests counting the numbers of loaded modules have distinct manifest file to execute the test +# individually, without any other test being executed before or after, as it could impact +# the number of loaded modules. +# This manifest file is for all the _other_ tests, where such setup isn't relevant. + +["browser_metrics_pool.js"] +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/metrics/browser_metrics_debugger.js b/devtools/client/framework/test/metrics/browser_metrics_debugger.js new file mode 100644 index 0000000000..4a684e40fc --- /dev/null +++ b/devtools/client/framework/test/metrics/browser_metrics_debugger.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test records the number of modules loaded by DevTools, as well as the total count + * of characters in those modules, when opening the debugger. These metrics are + * retrieved by perfherder via logs. + */ + +const TEST_URL = + "data:text/html;charset=UTF-8,<div>Debugger modules load test</div>"; + +add_task(async function () { + // Disable randomly spawning processes during tests + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + const toolbox = await openNewTabAndToolbox(TEST_URL, "jsdebugger"); + const toolboxBrowserLoader = toolbox.win.getBrowserLoaderForWindow(); + + // Retrieve the browser loader dedicated to the Debugger. + const panel = toolbox.getCurrentPanel(); + const debuggerLoader = panel.panelWin.getBrowserLoaderForWindow(); + + const loaders = [ + loader.loader, + toolboxBrowserLoader.loader, + debuggerLoader.loader, + ]; + + const allowedDupes = [ + "@loader/unload.js", + "@loader/options.js", + "resource://devtools/client/shared/vendor/fluent-react.js", + "resource://devtools/client/shared/vendor/react-dom.js", + "resource://devtools/client/shared/vendor/react.js", + "resource://devtools/client/shared/vendor/react-prop-types.js", + "resource://devtools/client/shared/vendor/react-dom-factories.js", + "resource://devtools/client/shared/vendor/react-redux.js", + "resource://devtools/client/shared/vendor/redux.js", + "resource://devtools/client/shared/redux/subscriber.js", + + "resource://devtools/client/shared/components/menu/MenuButton.js", + "resource://devtools/client/shared/components/menu/MenuItem.js", + "resource://devtools/client/shared/components/menu/MenuList.js", + ]; + runDuplicatedModulesTest(loaders, allowedDupes); + + runMetricsTest({ + filterString: "devtools/client/debugger", + loaders, + panelName: "debugger", + }); + + // See Bug 1637793 and Bug 1621337. + // Ideally the debugger should only resolve when the worker targets have been + // retrieved, which should be fixed by Bug 1621337 or a followup. + info("Wait for all pending requests to settle on the DevToolsClient"); + await toolbox.commands.client.waitForRequestsToSettle(); +}); diff --git a/devtools/client/framework/test/metrics/browser_metrics_debugger.toml b/devtools/client/framework/test/metrics/browser_metrics_debugger.toml new file mode 100644 index 0000000000..24d651095d --- /dev/null +++ b/devtools/client/framework/test/metrics/browser_metrics_debugger.toml @@ -0,0 +1,18 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "head.js", + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_metrics_debugger.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/metrics/browser_metrics_inspector.js b/devtools/client/framework/test/metrics/browser_metrics_inspector.js new file mode 100644 index 0000000000..284ef82372 --- /dev/null +++ b/devtools/client/framework/test/metrics/browser_metrics_inspector.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test records the number of modules loaded by DevTools, as well as the total count + * of characters in those modules, when opening the inspector. These metrics are retrieved + * by perfherder via logs. + */ + +const TEST_URL = + "data:text/html;charset=UTF-8,<div>Inspector modules load test</div>"; + +add_task(async function () { + const toolbox = await openNewTabAndToolbox(TEST_URL, "inspector"); + const toolboxBrowserLoader = toolbox.win.getBrowserLoaderForWindow(); + + // Most panels involve three loaders: + // - the global devtools loader + // - the browser loader used by the toolbox + // - a specific browser loader created for the panel + // But the inspector is a specific case, because it reuses the BrowserLoader + // of the toolbox to load its react components. This is why we only list + // two loaders here. + const loaders = [loader.loader, toolboxBrowserLoader.loader]; + + runDuplicatedModulesTest(loaders, [ + "@loader/unload.js", + "@loader/options.js", + "resource://devtools/client/shared/vendor/react.js", + "resource://devtools/client/shared/vendor/react-dom-factories.js", + "resource://devtools/client/shared/vendor/react-prop-types.js", + "resource://devtools/client/shared/vendor/redux.js", + "resource://devtools/client/shared/vendor/fluent-react.js", + ]); + + runMetricsTest({ + filterString: "devtools/client/inspector", + loaders, + panelName: "inspector", + }); +}); diff --git a/devtools/client/framework/test/metrics/browser_metrics_inspector.toml b/devtools/client/framework/test/metrics/browser_metrics_inspector.toml new file mode 100644 index 0000000000..62e2ec13ce --- /dev/null +++ b/devtools/client/framework/test/metrics/browser_metrics_inspector.toml @@ -0,0 +1,18 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "head.js", + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_metrics_inspector.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/metrics/browser_metrics_netmonitor.js b/devtools/client/framework/test/metrics/browser_metrics_netmonitor.js new file mode 100644 index 0000000000..07ae01eb95 --- /dev/null +++ b/devtools/client/framework/test/metrics/browser_metrics_netmonitor.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test records the number of modules loaded by DevTools, as well as the total count + * of characters in those modules, when opening the netmonitor. These metrics are + * retrieved by perfherder via logs. + */ + +const TEST_URL = + "data:text/html;charset=UTF-8,<div>Netmonitor modules load test</div>"; + +add_task(async function () { + const toolbox = await openNewTabAndToolbox(TEST_URL, "netmonitor"); + const toolboxBrowserLoader = toolbox.win.getBrowserLoaderForWindow(); + + // Retrieve the browser loader dedicated to the Netmonitor. + const panel = toolbox.getCurrentPanel(); + const netmonitorLoader = panel.panelWin.getBrowserLoaderForWindow(); + + const loaders = [ + loader.loader, + toolboxBrowserLoader.loader, + netmonitorLoader.loader, + ]; + + // Uncomment after Bug 1581068 is fixed, otherwise the test might fail too + // frequently. + + // const allowedDupes = [ + // "@loader/unload.js", + // "@loader/options.js", + // "resource://devtools/client/netmonitor/src/api.js", + // "resource://devtools/client/shared/vendor/redux.js", + // "resource://devtools/client/netmonitor/src/connector/index.js", + // "resource://devtools/client/netmonitor/src/create-store.js", + // "resource://devtools/client/netmonitor/src/constants.js", + // "resource://devtools/client/netmonitor/src/middleware/batching.js", + // "resource://devtools/client/netmonitor/src/middleware/prefs.js", + // "resource://devtools/client/netmonitor/src/middleware/recording.js", + // "resource://devtools/client/netmonitor/src/selectors/index.js", + // "resource://devtools/client/netmonitor/src/selectors/requests.js", + // "resource://devtools/client/shared/vendor/reselect.js", + // "resource://devtools/client/netmonitor/src/utils/filter-predicates.js", + // "resource://devtools/client/netmonitor/src/utils/filter-text-utils.js", + // "resource://devtools/client/netmonitor/src/utils/format-utils.js", + // "resource://devtools/client/netmonitor/src/utils/l10n.js", + // "resource://devtools/client/netmonitor/src/utils/sort-predicates.js", + // "resource://devtools/client/netmonitor/src/utils/request-utils.js", + // "resource://devtools/client/netmonitor/src/selectors/search.js", + // "resource://devtools/client/netmonitor/src/selectors/timing-markers.js", + // "resource://devtools/client/netmonitor/src/selectors/ui.js", + // "resource://devtools/client/netmonitor/src/selectors/messages.js", + // "resource://devtools/client/netmonitor/src/middleware/throttling.js", + // "resource://devtools/client/shared/components/throttling/actions.js", + // "resource://devtools/client/netmonitor/src/middleware/event-telemetry.js", + // "resource://devtools/client/netmonitor/src/reducers/index.js", + // "resource://devtools/client/netmonitor/src/reducers/batching.js", + // "resource://devtools/client/netmonitor/src/reducers/requests.js", + // "resource://devtools/client/netmonitor/src/reducers/search.js", + // "resource://devtools/client/netmonitor/src/reducers/sort.js", + // "resource://devtools/client/netmonitor/src/reducers/filters.js", + // "resource://devtools/client/netmonitor/src/reducers/timing-markers.js", + // "resource://devtools/client/netmonitor/src/reducers/ui.js", + // "resource://devtools/client/netmonitor/src/reducers/messages.js", + // "resource://devtools/client/shared/components/throttling/reducer.js", + // "resource://devtools/client/netmonitor/src/actions/index.js", + // "resource://devtools/client/netmonitor/src/actions/batching.js", + // "resource://devtools/client/netmonitor/src/actions/filters.js", + // "resource://devtools/client/netmonitor/src/actions/requests.js", + // "resource://devtools/client/netmonitor/src/actions/selection.js", + // "resource://devtools/client/netmonitor/src/actions/sort.js", + // "resource://devtools/client/netmonitor/src/actions/timing-markers.js", + // "resource://devtools/client/netmonitor/src/actions/ui.js", + // "resource://devtools/client/netmonitor/src/actions/messages.js", + // "resource://devtools/client/netmonitor/src/actions/search.js", + // "resource://devtools/client/netmonitor/src/workers/search/index.js", + // "resource://devtools/client/shared/worker-utils", + // ]; + // runDuplicatedModulesTest(loaders, allowedDupes); + + runMetricsTest({ + filterString: "devtools/client/netmonitor", + loaders, + panelName: "netmonitor", + }); +}); diff --git a/devtools/client/framework/test/metrics/browser_metrics_netmonitor.toml b/devtools/client/framework/test/metrics/browser_metrics_netmonitor.toml new file mode 100644 index 0000000000..2cef61b319 --- /dev/null +++ b/devtools/client/framework/test/metrics/browser_metrics_netmonitor.toml @@ -0,0 +1,18 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "head.js", + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_metrics_netmonitor.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/metrics/browser_metrics_pool.js b/devtools/client/framework/test/metrics/browser_metrics_pool.js new file mode 100644 index 0000000000..1b2231fef9 --- /dev/null +++ b/devtools/client/framework/test/metrics/browser_metrics_pool.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +const { Pool } = require("resource://devtools/shared/protocol.js"); + +// Test parameters +const ROOT_POOLS = 100; +const POOL_DEPTH = 10; +const POOLS_BY_LEVEL = 100; +// Number of Pools that will be added once the environment is set up. +const ADDITIONAL_POOLS = 5000; + +add_task(async function () { + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + const conn = DevToolsServer.connectPipe()._serverConnection; + + info("Add multiple Pools to the connection"); + const pools = setupTestEnvironment(conn); + + let sumResult = 0; + + info("Test how long it takes to manage new Pools"); + let start = performance.now(); + let parentPool = pools[pools.length - 1]; + const newPools = []; + for (let i = 0; i < ADDITIONAL_POOLS; i++) { + const pool = new Pool(conn, `${parentPool.label}-${i}`); + newPools.push(pool); + parentPool.manage(pool); + } + const manageResult = performance.now() - start; + sumResult += manageResult; + + info("Test how long it takes to manage Pools that were already managed"); + start = performance.now(); + parentPool = pools[pools.length - 2]; + for (const pool of newPools) { + parentPool.manage(pool); + } + const manageAlreadyManagedResult = performance.now() - start; + sumResult += manageAlreadyManagedResult; + + info("Test how long it takes to unmanage Pools"); + start = performance.now(); + for (const pool of newPools) { + parentPool.unmanage(pool); + } + const unmanageResult = performance.now() - start; + sumResult += unmanageResult; + + info("Test how long it takes to destroy all the Pools"); + start = performance.now(); + conn.onTransportClosed(); + const destroyResult = performance.now() - start; + sumResult += destroyResult; + + const PERFHERDER_DATA = { + framework: { + name: "devtools", + }, + suites: [ + { + name: "server.pool", + value: sumResult, + subtests: [ + { + name: "server.pool.manage", + value: manageResult, + }, + { + name: "server.pool.manage-already-managed", + value: manageAlreadyManagedResult, + }, + { + name: "server.pool.unmanage", + value: unmanageResult, + }, + { + name: "server.pool.destroy", + value: destroyResult, + }, + ], + }, + ], + }; + info("PERFHERDER_DATA: " + JSON.stringify(PERFHERDER_DATA)); +}); + +// Some Pool operations might be impacted by the number of existing pools in a connection, +// so it's important to have a sizeable number of Pools in order to assert Pool performances. +function setupTestEnvironment(conn) { + const pools = []; + for (let i = 0; i < ROOT_POOLS; i++) { + const rootPool = new Pool(conn, "root-pool-" + i); + pools.push(rootPool); + let parent = rootPool; + for (let j = 0; j < POOL_DEPTH; j++) { + const intermediatePool = new Pool(conn, `pool-${i}-${j}`); + pools.push(intermediatePool); + parent.manage(intermediatePool); + + for (let k = 0; k < POOLS_BY_LEVEL; k++) { + const pool = new Pool(conn, `pool-${i}-${j}-${k}`); + pools.push(pool); + intermediatePool.manage(pool); + } + + parent = intermediatePool; + } + } + return pools; +} diff --git a/devtools/client/framework/test/metrics/browser_metrics_webconsole.js b/devtools/client/framework/test/metrics/browser_metrics_webconsole.js new file mode 100644 index 0000000000..94e24291c8 --- /dev/null +++ b/devtools/client/framework/test/metrics/browser_metrics_webconsole.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test records the number of modules loaded by DevTools, as well as the total count + * of characters in those modules, when opening the webconsole. These metrics are + * retrieved by perfherder via logs. + */ + +const TEST_URL = + "data:text/html;charset=UTF-8,<div>Webconsole modules load test</div>"; + +add_task(async function () { + const toolbox = await openNewTabAndToolbox(TEST_URL, "webconsole"); + const toolboxBrowserLoader = toolbox.win.getBrowserLoaderForWindow(); + + // Retrieve the browser loader dedicated to the WebConsole. + const panel = toolbox.getCurrentPanel(); + const webconsoleLoader = panel._frameWindow.getBrowserLoaderForWindow(); + + const loaders = [ + loader.loader, + toolboxBrowserLoader.loader, + webconsoleLoader.loader, + ]; + + const allowedDupes = [ + "@loader/unload.js", + "@loader/options.js", + "resource://devtools/client/webconsole/constants.js", + "resource://devtools/client/webconsole/utils.js", + "resource://devtools/client/webconsole/utils/messages.js", + "resource://devtools/client/webconsole/utils/l10n.js", + "resource://devtools/client/netmonitor/src/utils/request-utils.js", + "resource://devtools/client/webconsole/types.js", + "resource://devtools/client/shared/components/menu/MenuButton.js", + "resource://devtools/client/shared/components/menu/MenuItem.js", + "resource://devtools/client/shared/components/menu/MenuList.js", + "resource://devtools/client/shared/vendor/fluent-react.js", + "resource://devtools/client/shared/vendor/react.js", + "resource://devtools/client/shared/vendor/react-dom.js", + "resource://devtools/client/shared/vendor/react-prop-types.js", + "resource://devtools/client/shared/vendor/react-dom-factories.js", + "resource://devtools/client/shared/vendor/redux.js", + "resource://devtools/client/shared/redux/middleware/thunk.js", + ]; + runDuplicatedModulesTest(loaders, allowedDupes); + + runMetricsTest({ + filterString: "devtools/client/webconsole", + loaders, + panelName: "webconsole", + }); +}); diff --git a/devtools/client/framework/test/metrics/browser_metrics_webconsole.toml b/devtools/client/framework/test/metrics/browser_metrics_webconsole.toml new file mode 100644 index 0000000000..e7c8831ad0 --- /dev/null +++ b/devtools/client/framework/test/metrics/browser_metrics_webconsole.toml @@ -0,0 +1,18 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "head.js", + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_metrics_webconsole.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/metrics/head.js b/devtools/client/framework/test/metrics/head.js new file mode 100644 index 0000000000..0246190b31 --- /dev/null +++ b/devtools/client/framework/test/metrics/head.js @@ -0,0 +1,171 @@ +/* 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 +); + +// So that PERFHERDER data can be extracted from the logs. +SimpleTest.requestCompleteLog(); + +function getFilteredModules(filters, loaders) { + let modules = []; + for (const l of loaders) { + const loaderModulesMap = l.modules; + const loaderModulesPaths = Object.keys(loaderModulesMap); + modules = modules.concat(loaderModulesPaths); + } + return modules.filter(url => filters.some(filter => url.includes(filter))); +} + +function countCharsInModules(modules) { + return modules.reduce((sum, uri) => { + try { + return sum + require("raw!" + uri).length; + } catch (e) { + // Ignore failures + return sum; + } + }, 0); +} + +/** + * Record module loading data. + * + * @param {Object} + * - filterString {String} path to use to filter modules specific to the current panel + * - loaders {Array} Array of Loaders to check for modules + * - panelName {String} reused in identifiers for perfherder data + */ +function runMetricsTest({ filterString, loaders, panelName }) { + const allModules = getFilteredModules([""], loaders); + const panelModules = getFilteredModules([filterString], loaders); + const vendoredModules = getFilteredModules( + ["devtools/client/debugger/dist/vendors", "devtools/client/shared/vendor/"], + loaders + ); + + const allModulesCount = allModules.length; + const panelModulesCount = panelModules.length; + const vendoredModulesCount = vendoredModules.length; + + const allModulesChars = countCharsInModules(allModules); + const panelModulesChars = countCharsInModules(panelModules); + const vendoredModulesChars = countCharsInModules(vendoredModules); + + const PERFHERDER_DATA = { + framework: { + name: "devtools", + }, + suites: [ + { + name: panelName + "-metrics", + value: allModulesChars, + subtests: [ + { + name: panelName + "-modules", + value: panelModulesCount, + }, + { + name: panelName + "-chars", + value: panelModulesChars, + }, + { + name: "all-modules", + value: allModulesCount, + }, + { + name: "all-chars", + value: allModulesChars, + }, + { + name: "vendored-modules", + value: vendoredModulesCount, + }, + { + name: "vendored-chars", + value: vendoredModulesChars, + }, + ], + }, + ], + }; + info("PERFHERDER_DATA: " + JSON.stringify(PERFHERDER_DATA)); + + // Simply check that we found valid values. + ok( + allModulesCount > panelModulesCount && panelModulesCount > 0, + "Successfully recorded module count for " + panelName + ); + ok( + allModulesChars > panelModulesChars && panelModulesChars > 0, + "Successfully recorded char count for " + panelName + ); + + // Easy way to check how many vendored chars we have for a given panel. + const percentage = ((100 * vendoredModulesChars) / allModulesChars).toFixed( + 1 + ); + info(`Percentage of vendored chars for ${panelName}: ${percentage}%`); +} + +function getDuplicatedModules(loaders) { + const allModules = getFilteredModules([""], loaders); + + const uniqueModules = new Set(); + const duplicatedModules = new Set(); + for (const mod of allModules) { + if (uniqueModules.has(mod)) { + duplicatedModules.add(mod); + } + + uniqueModules.add(mod); + } + + return duplicatedModules; +} + +/** + * Check that modules are only loaded once in a given set of loaders. + * Panels might load the same module twice by mistake if they are both using + * a BrowserLoader and the regular DevTools Loader. + * + * @param {Array} loaders + * Array of Loader instances. + * @param {Array} allowedDupes + * Array of Strings which are paths to known duplicated modules. + * The test will also fail if a allowedDupesed module is not found in the + * duplicated modules. + */ +function runDuplicatedModulesTest(loaders, allowedDupes) { + const duplicatedModules = getDuplicatedModules(loaders); + + // Remove allowedDupes entries, and fail if an allowed entry is not found. + for (const mod of allowedDupes) { + const deleted = duplicatedModules.delete(mod); + if (!deleted) { + ok( + false, + "module not found in the duplicated modules: [" + + mod + + "]. The allowedDupes array should be updated to remove it." + ); + } + } + + // Prepare a log string with the paths of all duplicated modules. + let duplicatedModulesLog = ""; + for (const mod of duplicatedModules) { + duplicatedModulesLog += ` [duplicated module] ${mod}\n`; + } + + // Check that duplicatedModules Set is empty. + is( + duplicatedModules.size, + 0, + "Duplicated module load detected. List of duplicated modules:\n" + + duplicatedModulesLog + ); +} diff --git a/devtools/client/framework/test/node/.eslintrc.js b/devtools/client/framework/test/node/.eslintrc.js new file mode 100644 index 0000000000..5bb10e35bf --- /dev/null +++ b/devtools/client/framework/test/node/.eslintrc.js @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +module.exports = { + env: { + jest: true, + }, + overrides: [ + { + files: [ + // Exempt all test files that explicitly want to test http urls from 'no-insecure-url' rule. + // Gradually change test cases such that this list gets smaller and more precisely. Bug 1758951 + "components/debug-target-info.test.js", + ], + rules: { + "@microsoft/sdl/no-insecure-url": "off", + }, + }, + ], +}; diff --git a/devtools/client/framework/test/node/README.md b/devtools/client/framework/test/node/README.md new file mode 100644 index 0000000000..9fb86edfc5 --- /dev/null +++ b/devtools/client/framework/test/node/README.md @@ -0,0 +1,22 @@ +# Jest Tests for devtools/client/framework + +## About + +DevTools React components can be tested using [jest](https://jestjs.io/). Jest allows to test our UI components in isolation and complement our end to end mochitests. + +## Run locally + +We use yarn for dependency management. To run the tests locally: +``` + cd devtools/client/shared/framework/test/node + yarn && yarn test +``` + +## Run on try + +The tests run on try on linux64 platforms. The complete name of the try job is `devtools-tests`. In treeherder, they will show up as `node(devtools)`. + +Adding the tests to a try push depends on the try selector you are using. +- try fuzzy: look for the job named `source-test-node-devtools-tests` + +The configuration file for try can be found at `taskcluster/ci/source-test/node.yml` diff --git a/devtools/client/framework/test/node/babel.config.js b/devtools/client/framework/test/node/babel.config.js new file mode 100644 index 0000000000..90cffba9c3 --- /dev/null +++ b/devtools/client/framework/test/node/babel.config.js @@ -0,0 +1,13 @@ +/* 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"; + +module.exports = { + plugins: [ + "@babel/plugin-proposal-async-generator-functions", + "@babel/plugin-proposal-optional-chaining", + "@babel/plugin-proposal-nullish-coalescing-operator", + ], +}; diff --git a/devtools/client/framework/test/node/components/__snapshots__/debug-target-info.test.js.snap b/devtools/client/framework/test/node/components/__snapshots__/debug-target-info.test.js.snap new file mode 100644 index 0000000000..0f6b1c9bab --- /dev/null +++ b/devtools/client/framework/test/node/components/__snapshots__/debug-target-info.test.js.snap @@ -0,0 +1,586 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DebugTargetInfo component Connection info renders the expected snapshot for USB Release target 1`] = ` +<header + className="debug-target-info qa-debug-target-info" +> + <span + className="iconized-label qa-connection-info" + > + <img + alt="usb icon" + src="chrome://devtools/skin/images/aboutdebugging-usb-icon.svg" + /> + toolbox.debugTargetInfo.connection.usb + </span> + <span + className="iconized-label qa-runtime-info" + > + <img + className="channel-icon qa-runtime-icon" + src="chrome://devtools/skin/images/aboutdebugging-firefox-release.svg" + /> + <b + className="devtools-ellipsis-text" + > + toolbox.debugTargetInfo.runtimeLabel-usbRuntimeBrandName-1.0.0 + </b> + <span + className="devtools-ellipsis-text" + > + usbDeviceName + </span> + </span> + <span + className="iconized-label debug-target-title" + > + <img + alt="toolbox.debugTargetInfo.targetType.tab" + src="chrome://devtools/skin/images/globe.svg" + /> + <b + className="devtools-ellipsis-text qa-target-title" + > + Test Tab Name + </b> + </span> + <div + className="debug-target-navigation" + > + <button + className="iconized-label navigation-button qa-back-button" + onClick={[Function]} + title="toolbox.debugTargetInfo.back" + > + <img + alt="toolbox.debugTargetInfo.back" + src="chrome://browser/skin/back.svg" + /> + </button> + <button + className="iconized-label navigation-button qa-forward-button" + onClick={[Function]} + title="toolbox.debugTargetInfo.forward" + > + <img + alt="toolbox.debugTargetInfo.forward" + src="chrome://browser/skin/forward.svg" + /> + </button> + <button + className="iconized-label navigation-button qa-reload-button" + onClick={[Function]} + title="toolbox.debugTargetInfo.reload" + > + <img + alt="toolbox.debugTargetInfo.reload" + src="chrome://global/skin/icons/reload.svg" + /> + </button> + </div> + <span + className="debug-target-url" + > + <form + className="debug-target-url-form" + onSubmit={[Function]} + > + <input + className="devtools-textinput debug-target-url-input" + defaultValue="http://some.target/url" + onChange={[Function]} + onFocus={[Function]} + /> + </form> + </span> +</header> +`; + +exports[`DebugTargetInfo component Target icon renders the expected snapshot for a process target 1`] = ` +<header + className="debug-target-info qa-debug-target-info" +> + <span + className="iconized-label qa-connection-info" + > + <img + alt="usb icon" + src="chrome://devtools/skin/images/aboutdebugging-usb-icon.svg" + /> + toolbox.debugTargetInfo.connection.usb + </span> + <span + className="iconized-label qa-runtime-info" + > + <img + className="channel-icon qa-runtime-icon" + src="chrome://devtools/skin/images/aboutdebugging-firefox-release.svg" + /> + <b + className="devtools-ellipsis-text" + > + toolbox.debugTargetInfo.runtimeLabel-usbRuntimeBrandName-1.0.0 + </b> + <span + className="devtools-ellipsis-text" + > + usbDeviceName + </span> + </span> + <span + className="iconized-label debug-target-title" + > + <img + alt="toolbox.debugTargetInfo.targetType.process" + src="chrome://devtools/skin/images/settings.svg" + /> + <b + className="devtools-ellipsis-text qa-target-title" + > + Test Tab Name + </b> + </span> + <span + className="debug-target-url" + > + <span + className="debug-target-url-readonly devtools-ellipsis-text" + > + http://some.target/url + </span> + </span> +</header> +`; + +exports[`DebugTargetInfo component Target icon renders the expected snapshot for a tab target 1`] = ` +<header + className="debug-target-info qa-debug-target-info" +> + <span + className="iconized-label qa-connection-info" + > + <img + alt="usb icon" + src="chrome://devtools/skin/images/aboutdebugging-usb-icon.svg" + /> + toolbox.debugTargetInfo.connection.usb + </span> + <span + className="iconized-label qa-runtime-info" + > + <img + className="channel-icon qa-runtime-icon" + src="chrome://devtools/skin/images/aboutdebugging-firefox-release.svg" + /> + <b + className="devtools-ellipsis-text" + > + toolbox.debugTargetInfo.runtimeLabel-usbRuntimeBrandName-1.0.0 + </b> + <span + className="devtools-ellipsis-text" + > + usbDeviceName + </span> + </span> + <span + className="iconized-label debug-target-title" + > + <img + alt="toolbox.debugTargetInfo.targetType.tab" + src="chrome://devtools/skin/images/globe.svg" + /> + <b + className="devtools-ellipsis-text qa-target-title" + > + Test Tab Name + </b> + </span> + <div + className="debug-target-navigation" + > + <button + className="iconized-label navigation-button qa-back-button" + onClick={[Function]} + title="toolbox.debugTargetInfo.back" + > + <img + alt="toolbox.debugTargetInfo.back" + src="chrome://browser/skin/back.svg" + /> + </button> + <button + className="iconized-label navigation-button qa-forward-button" + onClick={[Function]} + title="toolbox.debugTargetInfo.forward" + > + <img + alt="toolbox.debugTargetInfo.forward" + src="chrome://browser/skin/forward.svg" + /> + </button> + <button + className="iconized-label navigation-button qa-reload-button" + onClick={[Function]} + title="toolbox.debugTargetInfo.reload" + > + <img + alt="toolbox.debugTargetInfo.reload" + src="chrome://global/skin/icons/reload.svg" + /> + </button> + </div> + <span + className="debug-target-url" + > + <form + className="debug-target-url-form" + onSubmit={[Function]} + > + <input + className="devtools-textinput debug-target-url-input" + defaultValue="http://some.target/url" + onChange={[Function]} + onFocus={[Function]} + /> + </form> + </span> +</header> +`; + +exports[`DebugTargetInfo component Target icon renders the expected snapshot for a worker target 1`] = ` +<header + className="debug-target-info qa-debug-target-info" +> + <span + className="iconized-label qa-connection-info" + > + <img + alt="usb icon" + src="chrome://devtools/skin/images/aboutdebugging-usb-icon.svg" + /> + toolbox.debugTargetInfo.connection.usb + </span> + <span + className="iconized-label qa-runtime-info" + > + <img + className="channel-icon qa-runtime-icon" + src="chrome://devtools/skin/images/aboutdebugging-firefox-release.svg" + /> + <b + className="devtools-ellipsis-text" + > + toolbox.debugTargetInfo.runtimeLabel-usbRuntimeBrandName-1.0.0 + </b> + <span + className="devtools-ellipsis-text" + > + usbDeviceName + </span> + </span> + <span + className="iconized-label debug-target-title" + > + <img + alt="toolbox.debugTargetInfo.targetType.worker" + src="chrome://devtools/skin/images/debugging-workers.svg" + /> + <b + className="devtools-ellipsis-text qa-target-title" + > + Test Tab Name + </b> + </span> + <span + className="debug-target-url" + > + <span + className="debug-target-url-readonly devtools-ellipsis-text" + > + http://some.target/url + </span> + </span> +</header> +`; + +exports[`DebugTargetInfo component Target icon renders the expected snapshot for an extension target 1`] = ` +<header + className="debug-target-info qa-debug-target-info" +> + <span + className="iconized-label qa-connection-info" + > + <img + alt="usb icon" + src="chrome://devtools/skin/images/aboutdebugging-usb-icon.svg" + /> + toolbox.debugTargetInfo.connection.usb + </span> + <span + className="iconized-label qa-runtime-info" + > + <img + className="channel-icon qa-runtime-icon" + src="chrome://devtools/skin/images/aboutdebugging-firefox-release.svg" + /> + <b + className="devtools-ellipsis-text" + > + toolbox.debugTargetInfo.runtimeLabel-usbRuntimeBrandName-1.0.0 + </b> + <span + className="devtools-ellipsis-text" + > + usbDeviceName + </span> + </span> + <span + className="iconized-label debug-target-title" + > + <img + alt="toolbox.debugTargetInfo.targetType.extension" + src="chrome://devtools/skin/images/debugging-addons.svg" + /> + <b + className="devtools-ellipsis-text qa-target-title" + > + Test Tab Name + </b> + </span> + <div + className="debug-target-navigation" + > + <button + className="iconized-label navigation-button qa-reload-button" + onClick={[Function]} + title="toolbox.debugTargetInfo.reload" + > + <img + alt="toolbox.debugTargetInfo.reload" + src="chrome://global/skin/icons/reload.svg" + /> + </button> + </div> + <span + className="debug-target-url" + > + <span + className="debug-target-url-readonly devtools-ellipsis-text" + > + http://some.target/url + </span> + </span> +</header> +`; + +exports[`DebugTargetInfo component Target icon renders the expected snapshot for an local extension target 1`] = ` +<header + className="debug-target-info qa-debug-target-info" +> + <span + className="iconized-label debug-target-title" + > + <img + alt="toolbox.debugTargetInfo.targetType.extension" + src="chrome://devtools/skin/images/debugging-addons.svg" + /> + <b + className="devtools-ellipsis-text qa-target-title" + > + Test Tab Name + </b> + </span> + <div + className="debug-target-navigation" + > + <button + className="iconized-label navigation-button qa-reload-button" + onClick={[Function]} + title="toolbox.debugTargetInfo.reload" + > + <img + alt="toolbox.debugTargetInfo.reload" + src="chrome://global/skin/icons/reload.svg" + /> + </button> + </div> + <span + className="debug-target-url" + > + <span + className="debug-target-url-readonly devtools-ellipsis-text" + > + http://some.target/url + </span> + </span> + <button + className="toolbox-always-on-top" + /> +</header> +`; + +exports[`DebugTargetInfo component Target title renders the expected snapshot for This Firefox target 1`] = ` +<header + className="debug-target-info qa-debug-target-info" +> + <span + className="iconized-label qa-runtime-info" + > + <img + className="channel-icon qa-runtime-icon" + src="chrome://devtools/skin/images/aboutdebugging-firefox-release.svg" + /> + <b + className="devtools-ellipsis-text" + > + toolbox.debugTargetInfo.runtimeLabel.thisRuntime-brandShorterName-1.0.0 + </b> + <span + className="devtools-ellipsis-text" + /> + </span> + <span + className="iconized-label debug-target-title" + > + <img + alt="toolbox.debugTargetInfo.targetType.tab" + src="chrome://devtools/skin/images/globe.svg" + /> + <b + className="devtools-ellipsis-text qa-target-title" + > + Test Tab Name + </b> + </span> + <div + className="debug-target-navigation" + > + <button + className="iconized-label navigation-button qa-back-button" + onClick={[Function]} + title="toolbox.debugTargetInfo.back" + > + <img + alt="toolbox.debugTargetInfo.back" + src="chrome://browser/skin/back.svg" + /> + </button> + <button + className="iconized-label navigation-button qa-forward-button" + onClick={[Function]} + title="toolbox.debugTargetInfo.forward" + > + <img + alt="toolbox.debugTargetInfo.forward" + src="chrome://browser/skin/forward.svg" + /> + </button> + <button + className="iconized-label navigation-button qa-reload-button" + onClick={[Function]} + title="toolbox.debugTargetInfo.reload" + > + <img + alt="toolbox.debugTargetInfo.reload" + src="chrome://global/skin/icons/reload.svg" + /> + </button> + </div> + <span + className="debug-target-url" + > + <form + className="debug-target-url-form" + onSubmit={[Function]} + > + <input + className="devtools-textinput debug-target-url-input" + defaultValue="http://some.target/url" + onChange={[Function]} + onFocus={[Function]} + /> + </form> + </span> +</header> +`; + +exports[`DebugTargetInfo component Target title renders the expected snapshot for a Toolbox with an unnamed target 1`] = ` +<header + className="debug-target-info qa-debug-target-info" +> + <span + className="iconized-label qa-runtime-info" + > + <img + className="channel-icon qa-runtime-icon" + src="chrome://devtools/skin/images/aboutdebugging-firefox-release.svg" + /> + <b + className="devtools-ellipsis-text" + > + toolbox.debugTargetInfo.runtimeLabel.thisRuntime-brandShorterName-1.0.0 + </b> + <span + className="devtools-ellipsis-text" + /> + </span> + <span + className="iconized-label debug-target-title" + > + <img + alt="toolbox.debugTargetInfo.targetType.tab" + src="chrome://devtools/skin/images/globe.svg" + /> + </span> + <div + className="debug-target-navigation" + > + <button + className="iconized-label navigation-button qa-back-button" + onClick={[Function]} + title="toolbox.debugTargetInfo.back" + > + <img + alt="toolbox.debugTargetInfo.back" + src="chrome://browser/skin/back.svg" + /> + </button> + <button + className="iconized-label navigation-button qa-forward-button" + onClick={[Function]} + title="toolbox.debugTargetInfo.forward" + > + <img + alt="toolbox.debugTargetInfo.forward" + src="chrome://browser/skin/forward.svg" + /> + </button> + <button + className="iconized-label navigation-button qa-reload-button" + onClick={[Function]} + title="toolbox.debugTargetInfo.reload" + > + <img + alt="toolbox.debugTargetInfo.reload" + src="chrome://global/skin/icons/reload.svg" + /> + </button> + </div> + <span + className="debug-target-url" + > + <form + className="debug-target-url-form" + onSubmit={[Function]} + > + <input + className="devtools-textinput debug-target-url-input" + defaultValue="http://some.target/without/a/name" + onChange={[Function]} + onFocus={[Function]} + /> + </form> + </span> +</header> +`; diff --git a/devtools/client/framework/test/node/components/debug-target-info.test.js b/devtools/client/framework/test/node/components/debug-target-info.test.js new file mode 100644 index 0000000000..45a04007ad --- /dev/null +++ b/devtools/client/framework/test/node/components/debug-target-info.test.js @@ -0,0 +1,319 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Unit tests for the DebugTargetInfo component. + */ + +const renderer = require("react-test-renderer"); +const React = require("resource://devtools/client/shared/vendor/react.js"); +const DebugTargetInfo = React.createFactory( + require("resource://devtools/client/framework/components/DebugTargetInfo.js") +); +const { + CONNECTION_TYPES, +} = require("resource://devtools/client/shared/remote-debugging/constants.js"); +const DESCRIPTOR_TYPES = require("resource://devtools/client/fronts/descriptors/descriptor-types.js"); + +/** + * Stub for the L10N property expected by the DebugTargetInfo component. + */ +const stubL10N = { + getStr: id => id, + getFormatStr: (id, ...args) => [id, ...args].join("-"), +}; + +const findByClassName = (testInstance, className) => { + return testInstance.findAll(node => { + return node.props.className && node.props.className.includes(className); + }); +}; + +function buildProps(base, extraDebugTargetData) { + const props = Object.assign({}, base); + Object.assign(props.debugTargetData, extraDebugTargetData); + return props; +} + +const TEST_TOOLBOX = { + target: { + name: "Test Tab Name", + url: "http://some.target/url", + targetForm: { + traits: { + navigation: true, + }, + }, + getTrait: trait => { + return TEST_TOOLBOX.target.targetForm.traits[trait]; + }, + }, + doc: {}, +}; + +const TEST_TOOLBOX_NO_NAME = { + target: { + url: "http://some.target/without/a/name", + targetForm: { + traits: { + navigation: true, + }, + }, + getTrait: trait => { + return TEST_TOOLBOX.target.targetForm.traits[trait]; + }, + }, + doc: {}, +}; + +const USB_DEVICE_DESCRIPTION = { + deviceName: "usbDeviceName", + icon: "chrome://devtools/skin/images/aboutdebugging-firefox-release.svg", + name: "usbRuntimeBrandName", + version: "1.0.0", +}; + +const THIS_FIREFOX_DEVICE_DESCRIPTION = { + icon: "chrome://devtools/skin/images/aboutdebugging-firefox-release.svg", + version: "1.0.0", + name: "thisFirefoxRuntimeBrandName", +}; + +const USB_TARGET_INFO = { + debugTargetData: { + connectionType: CONNECTION_TYPES.USB, + runtimeInfo: USB_DEVICE_DESCRIPTION, + descriptorType: DESCRIPTOR_TYPES.TAB, + }, + toolbox: TEST_TOOLBOX, + L10N: stubL10N, +}; + +const THIS_FIREFOX_TARGET_INFO = { + debugTargetData: { + connectionType: CONNECTION_TYPES.THIS_FIREFOX, + runtimeInfo: THIS_FIREFOX_DEVICE_DESCRIPTION, + descriptorType: DESCRIPTOR_TYPES.TAB, + }, + toolbox: TEST_TOOLBOX, + L10N: stubL10N, +}; + +const THIS_FIREFOX_NO_NAME_TARGET_INFO = { + debugTargetData: { + connectionType: CONNECTION_TYPES.THIS_FIREFOX, + runtimeInfo: THIS_FIREFOX_DEVICE_DESCRIPTION, + descriptorType: DESCRIPTOR_TYPES.TAB, + }, + toolbox: TEST_TOOLBOX_NO_NAME, + L10N: stubL10N, +}; + +describe("DebugTargetInfo component", () => { + describe("Connection info", () => { + it("displays connection info for USB Release target", () => { + const component = renderer.create(DebugTargetInfo(USB_TARGET_INFO)); + expect( + findByClassName(component.root, "qa-connection-info").length + ).toEqual(1); + }); + + it("renders the expected snapshot for USB Release target", () => { + const component = renderer.create(DebugTargetInfo(USB_TARGET_INFO)); + expect(component.toJSON()).toMatchSnapshot(); + }); + + it("hides the connection info for This Firefox target", () => { + const component = renderer.create( + DebugTargetInfo(THIS_FIREFOX_TARGET_INFO) + ); + expect( + findByClassName(component.root, "qa-connection-info").length + ).toEqual(0); + }); + }); + + describe("Target title", () => { + it("displays the target title if the target of the Toolbox has a name", () => { + const component = renderer.create( + DebugTargetInfo(THIS_FIREFOX_TARGET_INFO) + ); + expect(findByClassName(component.root, "qa-target-title").length).toEqual( + 1 + ); + }); + + it("renders the expected snapshot for This Firefox target", () => { + const component = renderer.create( + DebugTargetInfo(THIS_FIREFOX_TARGET_INFO) + ); + expect(component.toJSON()).toMatchSnapshot(); + }); + + it("doesn't display the target title if the target of the Toolbox has no name", () => { + const component = renderer.create( + DebugTargetInfo(THIS_FIREFOX_NO_NAME_TARGET_INFO) + ); + expect(findByClassName(component.root, "qa-target-title").length).toEqual( + 0 + ); + }); + + it("renders the expected snapshot for a Toolbox with an unnamed target", () => { + const component = renderer.create( + DebugTargetInfo(THIS_FIREFOX_NO_NAME_TARGET_INFO) + ); + expect(component.toJSON()).toMatchSnapshot(); + }); + }); + + describe("Target icon", () => { + it("renders the expected snapshot for a tab target", () => { + const props = buildProps(USB_TARGET_INFO, { + descriptorType: DESCRIPTOR_TYPES.TAB, + }); + const component = renderer.create(DebugTargetInfo(props)); + expect(component.toJSON()).toMatchSnapshot(); + }); + + it("renders the expected snapshot for a worker target", () => { + const props = buildProps(USB_TARGET_INFO, { + descriptorType: DESCRIPTOR_TYPES.WORKER, + }); + const component = renderer.create(DebugTargetInfo(props)); + expect(component.toJSON()).toMatchSnapshot(); + }); + + it("renders the expected snapshot for an extension target", () => { + const props = buildProps(USB_TARGET_INFO, { + descriptorType: DESCRIPTOR_TYPES.EXTENSION, + }); + const component = renderer.create(DebugTargetInfo(props)); + expect(component.toJSON()).toMatchSnapshot(); + }); + + it("renders the expected snapshot for an local extension target", () => { + const props = buildProps(THIS_FIREFOX_TARGET_INFO, { + descriptorType: DESCRIPTOR_TYPES.EXTENSION, + }); + const component = renderer.create(DebugTargetInfo(props)); + expect(component.toJSON()).toMatchSnapshot(); + }); + + it("renders the expected snapshot for a process target", () => { + const props = buildProps(USB_TARGET_INFO, { + descriptorType: DESCRIPTOR_TYPES.PROCESS, + }); + const component = renderer.create(DebugTargetInfo(props)); + expect(component.toJSON()).toMatchSnapshot(); + }); + }); + + describe("Always on top button", () => { + it("displays always on top button for local webextension target", () => { + const component = renderer.create( + DebugTargetInfo( + buildProps(THIS_FIREFOX_TARGET_INFO, { + descriptorType: DESCRIPTOR_TYPES.EXTENSION, + }) + ) + ); + expect( + findByClassName(component.root, "toolbox-always-on-top").length + ).toEqual(1); + }); + + it(`does not display "Always on top" button for remote webextension toolbox`, () => { + const component = renderer.create( + DebugTargetInfo( + buildProps(USB_TARGET_INFO, { + descriptorType: DESCRIPTOR_TYPES.EXTENSION, + }) + ) + ); + expect( + findByClassName(component.root, "toolbox-always-on-top").length + ).toEqual(0); + }); + + it(`does not display "Always on top" button for local tab toolbox`, () => { + const component = renderer.create( + DebugTargetInfo( + buildProps(THIS_FIREFOX_TARGET_INFO, { + descriptorType: DESCRIPTOR_TYPES.TAB, + }) + ) + ); + expect( + findByClassName(component.root, "toolbox-always-on-top").length + ).toEqual(0); + }); + + it(`does not display "Always on top" button for remote tab toolbox`, () => { + const component = renderer.create( + DebugTargetInfo( + buildProps(USB_TARGET_INFO, { + descriptorType: DESCRIPTOR_TYPES.TAB, + }) + ) + ); + expect( + findByClassName(component.root, "toolbox-always-on-top").length + ).toEqual(0); + }); + + it(`does not display "Always on top" button for local worker toolbox`, () => { + const component = renderer.create( + DebugTargetInfo( + buildProps(THIS_FIREFOX_TARGET_INFO, { + descriptorType: DESCRIPTOR_TYPES.WORKER, + }) + ) + ); + expect( + findByClassName(component.root, "toolbox-always-on-top").length + ).toEqual(0); + }); + + it(`does not display "Always on top" button for remote worker toolbox`, () => { + const component = renderer.create( + DebugTargetInfo( + buildProps(USB_TARGET_INFO, { + descriptorType: DESCRIPTOR_TYPES.WORKER, + }) + ) + ); + expect( + findByClassName(component.root, "toolbox-always-on-top").length + ).toEqual(0); + }); + + it(`does not display "Always on top" button for local process toolbox`, () => { + const component = renderer.create( + DebugTargetInfo( + buildProps(THIS_FIREFOX_TARGET_INFO, { + descriptorType: DESCRIPTOR_TYPES.PROCESS, + }) + ) + ); + expect( + findByClassName(component.root, "toolbox-always-on-top").length + ).toEqual(0); + }); + + it(`does not display "Always on top" button for remote process toolbox`, () => { + const component = renderer.create( + DebugTargetInfo( + buildProps(USB_TARGET_INFO, { + descriptorType: DESCRIPTOR_TYPES.PROCESS, + }) + ) + ); + expect( + findByClassName(component.root, "toolbox-always-on-top").length + ).toEqual(0); + }); + }); +}); diff --git a/devtools/client/framework/test/node/jest.config.js b/devtools/client/framework/test/node/jest.config.js new file mode 100644 index 0000000000..0d2124593d --- /dev/null +++ b/devtools/client/framework/test/node/jest.config.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* global __dirname */ + +const sharedJestConfig = require(`${__dirname}/../../../shared/test-helpers/shared-jest.config`); + +module.exports = { + ...sharedJestConfig, + setupFiles: ["<rootDir>setup.js"], +}; diff --git a/devtools/client/framework/test/node/package.json b/devtools/client/framework/test/node/package.json new file mode 100644 index 0000000000..37237a8c57 --- /dev/null +++ b/devtools/client/framework/test/node/package.json @@ -0,0 +1,22 @@ +{ + "name": "devtools-client-framework-tests", + "license": "MPL-2.0", + "version": "0.0.1", + "engines": { + "node": ">=8.9.4" + }, + "scripts": { + "test": "jest", + "test-ci": "jest --json" + }, + "dependencies": { + "@babel/plugin-proposal-async-generator-functions": "^7.2.0", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-proposal-optional-chaining": "^7.8.3", + "babel-jest": "^25.1.0", + "jest": "^25.1.0", + "react-test-renderer": "16.4.1", + "react": "16.4.1", + "react-dom": "16.4.1" + } +} diff --git a/devtools/client/framework/test/node/setup.js b/devtools/client/framework/test/node/setup.js new file mode 100644 index 0000000000..fd686f0cc0 --- /dev/null +++ b/devtools/client/framework/test/node/setup.js @@ -0,0 +1,10 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +const { + setMocksInGlobal, +} = require("resource://devtools/client/shared/test-helpers/shared-node-helpers.js"); +setMocksInGlobal(); diff --git a/devtools/client/framework/test/node/store/targets.test.js b/devtools/client/framework/test/node/store/targets.test.js new file mode 100644 index 0000000000..5eb8d2b5ef --- /dev/null +++ b/devtools/client/framework/test/node/store/targets.test.js @@ -0,0 +1,142 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Unit tests for targets management on the toolbox store. + */ + +const createStore = require("resource://devtools/client/shared/redux/create-store.js"); +const reducer = require("resource://devtools/shared/commands/target/reducers/targets.js"); +const actions = require("resource://devtools/shared/commands/target/actions/targets.js"); +const { + getSelectedTarget, + getToolboxTargets, +} = require("resource://devtools/shared/commands/target/selectors/targets.js"); + +describe("Toolbox store - targets", () => { + describe("registerTarget", () => { + it("adds the target to the list", () => { + const store = createStore(reducer); + + const targetFront1 = { + actorID: "target/1", + }; + + store.dispatch(actions.registerTarget(targetFront1)); + + let targets = getToolboxTargets(store.getState()); + expect(targets.length).toEqual(1); + expect(targets[0].actorID).toEqual("target/1"); + + const targetFront2 = { + actorID: "target/2", + }; + + store.dispatch(actions.registerTarget(targetFront2)); + + targets = getToolboxTargets(store.getState()); + expect(targets.length).toEqual(2); + expect(targets[0].actorID).toEqual("target/1"); + expect(targets[1].actorID).toEqual("target/2"); + }); + }); + + describe("selectTarget", () => { + it("updates the selected property when the target is known", () => { + const store = createStore(reducer); + const targetFront1 = { + actorID: "target/1", + }; + store.dispatch(actions.registerTarget(targetFront1)); + store.dispatch(actions.selectTarget("target/1")); + expect(getSelectedTarget(store.getState()).actorID).toBe("target/1"); + }); + + it("does not update the selected property when the target is unknown", () => { + const store = createStore(reducer); + const targetFront1 = { + actorID: "target/1", + }; + store.dispatch(actions.registerTarget(targetFront1)); + store.dispatch(actions.selectTarget("target/1")); + expect(getSelectedTarget(store.getState()).actorID).toBe("target/1"); + + store.dispatch(actions.selectTarget("target/unknown")); + expect(getSelectedTarget(store.getState()).actorID).toBe("target/1"); + }); + + it("does not update the state when the target is already selected", () => { + const store = createStore(reducer); + const targetFront1 = { + actorID: "target/1", + }; + store.dispatch(actions.registerTarget(targetFront1)); + store.dispatch(actions.selectTarget("target/1")); + + const state = store.getState(); + store.dispatch(actions.selectTarget("target/1")); + expect(store.getState()).toStrictEqual(state); + }); + }); + + describe("unregisterTarget", () => { + it("removes the target from the list", () => { + const store = createStore(reducer); + + const targetFront1 = { + actorID: "target/1", + }; + const targetFront2 = { + actorID: "target/2", + }; + + store.dispatch(actions.registerTarget(targetFront1)); + store.dispatch(actions.registerTarget(targetFront2)); + + let targets = getToolboxTargets(store.getState()); + expect(targets.length).toEqual(2); + + store.dispatch(actions.unregisterTarget(targetFront1)); + targets = getToolboxTargets(store.getState()); + expect(targets.length).toEqual(1); + expect(targets[0].actorID).toEqual("target/2"); + + store.dispatch(actions.unregisterTarget(targetFront2)); + expect(getToolboxTargets(store.getState()).length).toEqual(0); + }); + + it("does not update the state when the target is unknown", () => { + const store = createStore(reducer); + + const targetFront1 = { + actorID: "target/1", + }; + const targetFront2 = { + actorID: "target/unknown", + }; + + store.dispatch(actions.registerTarget(targetFront1)); + + const state = store.getState(); + store.dispatch(actions.unregisterTarget(targetFront2)); + expect(store.getState()).toStrictEqual(state); + }); + + it("resets the selected property when it was the selected target", () => { + const store = createStore(reducer); + + const targetFront1 = { + actorID: "target/1", + }; + + store.dispatch(actions.registerTarget(targetFront1)); + store.dispatch(actions.selectTarget("target/1")); + expect(getSelectedTarget(store.getState()).actorID).toBe("target/1"); + + store.dispatch(actions.unregisterTarget(targetFront1)); + expect(getSelectedTarget(store.getState())).toBe(null); + }); + }); +}); diff --git a/devtools/client/framework/test/node/yarn.lock b/devtools/client/framework/test/node/yarn.lock new file mode 100644 index 0000000000..2a71218b83 --- /dev/null +++ b/devtools/client/framework/test/node/yarn.lock @@ -0,0 +1,3144 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.8.3.tgz#33e25903d7481181534e12ec0a25f16b6fcf419e" + dependencies: + "@babel/highlight" "^7.8.3" + +"@babel/core@^7.1.0", "@babel/core@^7.7.5": + version "7.9.0" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.9.0.tgz#ac977b538b77e132ff706f3b8a4dbad09c03c56e" + dependencies: + "@babel/code-frame" "^7.8.3" + "@babel/generator" "^7.9.0" + "@babel/helper-module-transforms" "^7.9.0" + "@babel/helpers" "^7.9.0" + "@babel/parser" "^7.9.0" + "@babel/template" "^7.8.6" + "@babel/traverse" "^7.9.0" + "@babel/types" "^7.9.0" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.1" + json5 "^2.1.2" + lodash "^4.17.13" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + +"@babel/generator@^7.9.0": + version "7.9.4" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.9.4.tgz#12441e90c3b3c4159cdecf312075bf1a8ce2dbce" + dependencies: + "@babel/types" "^7.9.0" + jsesc "^2.5.1" + lodash "^4.17.13" + source-map "^0.5.0" + +"@babel/helper-annotate-as-pure@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.8.3.tgz#60bc0bc657f63a0924ff9a4b4a0b24a13cf4deee" + dependencies: + "@babel/types" "^7.8.3" + +"@babel/helper-function-name@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz#eeeb665a01b1f11068e9fb86ad56a1cb1a824cca" + dependencies: + "@babel/helper-get-function-arity" "^7.8.3" + "@babel/template" "^7.8.3" + "@babel/types" "^7.8.3" + +"@babel/helper-get-function-arity@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz#b894b947bd004381ce63ea1db9f08547e920abd5" + dependencies: + "@babel/types" "^7.8.3" + +"@babel/helper-member-expression-to-functions@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz#659b710498ea6c1d9907e0c73f206eee7dadc24c" + dependencies: + "@babel/types" "^7.8.3" + +"@babel/helper-module-imports@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.8.3.tgz#7fe39589b39c016331b6b8c3f441e8f0b1419498" + dependencies: + "@babel/types" "^7.8.3" + +"@babel/helper-module-transforms@^7.9.0": + version "7.9.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.9.0.tgz#43b34dfe15961918707d247327431388e9fe96e5" + dependencies: + "@babel/helper-module-imports" "^7.8.3" + "@babel/helper-replace-supers" "^7.8.6" + "@babel/helper-simple-access" "^7.8.3" + "@babel/helper-split-export-declaration" "^7.8.3" + "@babel/template" "^7.8.6" + "@babel/types" "^7.9.0" + lodash "^4.17.13" + +"@babel/helper-optimise-call-expression@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz#7ed071813d09c75298ef4f208956006b6111ecb9" + dependencies: + "@babel/types" "^7.8.3" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz#9ea293be19babc0f52ff8ca88b34c3611b208670" + +"@babel/helper-remap-async-to-generator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.8.3.tgz#273c600d8b9bf5006142c1e35887d555c12edd86" + dependencies: + "@babel/helper-annotate-as-pure" "^7.8.3" + "@babel/helper-wrap-function" "^7.8.3" + "@babel/template" "^7.8.3" + "@babel/traverse" "^7.8.3" + "@babel/types" "^7.8.3" + +"@babel/helper-replace-supers@^7.8.6": + version "7.8.6" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.8.6.tgz#5ada744fd5ad73203bf1d67459a27dcba67effc8" + dependencies: + "@babel/helper-member-expression-to-functions" "^7.8.3" + "@babel/helper-optimise-call-expression" "^7.8.3" + "@babel/traverse" "^7.8.6" + "@babel/types" "^7.8.6" + +"@babel/helper-simple-access@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.8.3.tgz#7f8109928b4dab4654076986af575231deb639ae" + dependencies: + "@babel/template" "^7.8.3" + "@babel/types" "^7.8.3" + +"@babel/helper-split-export-declaration@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz#31a9f30070f91368a7182cf05f831781065fc7a9" + dependencies: + "@babel/types" "^7.8.3" + +"@babel/helper-validator-identifier@^7.9.0": + version "7.9.0" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.0.tgz#ad53562a7fc29b3b9a91bbf7d10397fd146346ed" + +"@babel/helper-wrap-function@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.8.3.tgz#9dbdb2bb55ef14aaa01fe8c99b629bd5352d8610" + dependencies: + "@babel/helper-function-name" "^7.8.3" + "@babel/template" "^7.8.3" + "@babel/traverse" "^7.8.3" + "@babel/types" "^7.8.3" + +"@babel/helpers@^7.9.0": + version "7.9.2" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.9.2.tgz#b42a81a811f1e7313b88cba8adc66b3d9ae6c09f" + dependencies: + "@babel/template" "^7.8.3" + "@babel/traverse" "^7.9.0" + "@babel/types" "^7.9.0" + +"@babel/highlight@^7.8.3": + version "7.9.0" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.9.0.tgz#4e9b45ccb82b79607271b2979ad82c7b68163079" + dependencies: + "@babel/helper-validator-identifier" "^7.9.0" + chalk "^2.0.0" + js-tokens "^4.0.0" + +"@babel/parser@^7.1.0", "@babel/parser@^7.7.5", "@babel/parser@^7.8.6", "@babel/parser@^7.9.0": + version "7.9.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.9.4.tgz#68a35e6b0319bbc014465be43828300113f2f2e8" + +"@babel/plugin-proposal-async-generator-functions@^7.2.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.8.3.tgz#bad329c670b382589721b27540c7d288601c6e6f" + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/helper-remap-async-to-generator" "^7.8.3" + "@babel/plugin-syntax-async-generators" "^7.8.0" + +"@babel/plugin-proposal-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.8.3.tgz#e4572253fdeed65cddeecfdab3f928afeb2fd5d2" + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" + +"@babel/plugin-proposal-optional-chaining@^7.8.3": + version "7.9.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.9.0.tgz#31db16b154c39d6b8a645292472b98394c292a58" + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.0" + +"@babel/plugin-syntax-async-generators@^7.8.0": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-bigint@^7.0.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-object-rest-spread@^7.0.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6": + version "7.8.6" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" + dependencies: + "@babel/code-frame" "^7.8.3" + "@babel/parser" "^7.8.6" + "@babel/types" "^7.8.6" + +"@babel/traverse@^7.1.0", "@babel/traverse@^7.7.4", "@babel/traverse@^7.8.3", "@babel/traverse@^7.8.6", "@babel/traverse@^7.9.0": + version "7.9.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.9.0.tgz#d3882c2830e513f4fe4cec9fe76ea1cc78747892" + dependencies: + "@babel/code-frame" "^7.8.3" + "@babel/generator" "^7.9.0" + "@babel/helper-function-name" "^7.8.3" + "@babel/helper-split-export-declaration" "^7.8.3" + "@babel/parser" "^7.9.0" + "@babel/types" "^7.9.0" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.13" + +"@babel/types@^7.0.0", "@babel/types@^7.3.0", "@babel/types@^7.8.3", "@babel/types@^7.8.6", "@babel/types@^7.9.0": + version "7.9.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.9.0.tgz#00b064c3df83ad32b2dbf5ff07312b15c7f1efb5" + dependencies: + "@babel/helper-validator-identifier" "^7.9.0" + lodash "^4.17.13" + to-fast-properties "^2.0.0" + +"@bcoe/v8-coverage@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" + +"@cnakazawa/watch@^1.0.3": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.4.tgz#f864ae85004d0fcab6f50be9141c4da368d1656a" + dependencies: + exec-sh "^0.3.2" + minimist "^1.2.0" + +"@istanbuljs/load-nyc-config@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz#10602de5570baea82f8afbfa2630b24e7a8cfe5b" + dependencies: + camelcase "^5.3.1" + find-up "^4.1.0" + js-yaml "^3.13.1" + resolve-from "^5.0.0" + +"@istanbuljs/schema@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd" + +"@jest/console@^25.1.0": + version "25.1.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-25.1.0.tgz#1fc765d44a1e11aec5029c08e798246bd37075ab" + dependencies: + "@jest/source-map" "^25.1.0" + chalk "^3.0.0" + jest-util "^25.1.0" + slash "^3.0.0" + +"@jest/core@^25.1.0": + version "25.1.0" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-25.1.0.tgz#3d4634fc3348bb2d7532915d67781cdac0869e47" + dependencies: + "@jest/console" "^25.1.0" + "@jest/reporters" "^25.1.0" + "@jest/test-result" "^25.1.0" + "@jest/transform" "^25.1.0" + "@jest/types" "^25.1.0" + ansi-escapes "^4.2.1" + chalk "^3.0.0" + exit "^0.1.2" + graceful-fs "^4.2.3" + jest-changed-files "^25.1.0" + jest-config "^25.1.0" + jest-haste-map "^25.1.0" + jest-message-util "^25.1.0" + jest-regex-util "^25.1.0" + jest-resolve "^25.1.0" + jest-resolve-dependencies "^25.1.0" + jest-runner "^25.1.0" + jest-runtime "^25.1.0" + jest-snapshot "^25.1.0" + jest-util "^25.1.0" + jest-validate "^25.1.0" + jest-watcher "^25.1.0" + micromatch "^4.0.2" + p-each-series "^2.1.0" + realpath-native "^1.1.0" + rimraf "^3.0.0" + slash "^3.0.0" + strip-ansi "^6.0.0" + +"@jest/environment@^25.1.0": + version "25.1.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-25.1.0.tgz#4a97f64770c9d075f5d2b662b5169207f0a3f787" + dependencies: + "@jest/fake-timers" "^25.1.0" + "@jest/types" "^25.1.0" + jest-mock "^25.1.0" + +"@jest/fake-timers@^25.1.0": + version "25.1.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-25.1.0.tgz#a1e0eff51ffdbb13ee81f35b52e0c1c11a350ce8" + dependencies: + "@jest/types" "^25.1.0" + jest-message-util "^25.1.0" + jest-mock "^25.1.0" + jest-util "^25.1.0" + lolex "^5.0.0" + +"@jest/reporters@^25.1.0": + version "25.1.0" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-25.1.0.tgz#9178ecf136c48f125674ac328f82ddea46e482b0" + dependencies: + "@bcoe/v8-coverage" "^0.2.3" + "@jest/console" "^25.1.0" + "@jest/environment" "^25.1.0" + "@jest/test-result" "^25.1.0" + "@jest/transform" "^25.1.0" + "@jest/types" "^25.1.0" + chalk "^3.0.0" + collect-v8-coverage "^1.0.0" + exit "^0.1.2" + glob "^7.1.2" + istanbul-lib-coverage "^3.0.0" + istanbul-lib-instrument "^4.0.0" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^4.0.0" + istanbul-reports "^3.0.0" + jest-haste-map "^25.1.0" + jest-resolve "^25.1.0" + jest-runtime "^25.1.0" + jest-util "^25.1.0" + jest-worker "^25.1.0" + slash "^3.0.0" + source-map "^0.6.0" + string-length "^3.1.0" + terminal-link "^2.0.0" + v8-to-istanbul "^4.0.1" + optionalDependencies: + node-notifier "^6.0.0" + +"@jest/source-map@^25.1.0": + version "25.1.0" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-25.1.0.tgz#b012e6c469ccdbc379413f5c1b1ffb7ba7034fb0" + dependencies: + callsites "^3.0.0" + graceful-fs "^4.2.3" + source-map "^0.6.0" + +"@jest/test-result@^25.1.0": + version "25.1.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-25.1.0.tgz#847af2972c1df9822a8200457e64be4ff62821f7" + dependencies: + "@jest/console" "^25.1.0" + "@jest/transform" "^25.1.0" + "@jest/types" "^25.1.0" + "@types/istanbul-lib-coverage" "^2.0.0" + collect-v8-coverage "^1.0.0" + +"@jest/test-sequencer@^25.1.0": + version "25.1.0" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-25.1.0.tgz#4df47208542f0065f356fcdb80026e3c042851ab" + dependencies: + "@jest/test-result" "^25.1.0" + jest-haste-map "^25.1.0" + jest-runner "^25.1.0" + jest-runtime "^25.1.0" + +"@jest/transform@^25.1.0": + version "25.1.0" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-25.1.0.tgz#221f354f512b4628d88ce776d5b9e601028ea9da" + dependencies: + "@babel/core" "^7.1.0" + "@jest/types" "^25.1.0" + babel-plugin-istanbul "^6.0.0" + chalk "^3.0.0" + convert-source-map "^1.4.0" + fast-json-stable-stringify "^2.0.0" + graceful-fs "^4.2.3" + jest-haste-map "^25.1.0" + jest-regex-util "^25.1.0" + jest-util "^25.1.0" + micromatch "^4.0.2" + pirates "^4.0.1" + realpath-native "^1.1.0" + slash "^3.0.0" + source-map "^0.6.1" + write-file-atomic "^3.0.0" + +"@jest/types@^25.1.0": + version "25.1.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-25.1.0.tgz#b26831916f0d7c381e11dbb5e103a72aed1b4395" + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^1.1.1" + "@types/yargs" "^15.0.0" + chalk "^3.0.0" + +"@sinonjs/commons@^1.7.0": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.1.tgz#da5fd19a5f71177a53778073978873964f49acf1" + dependencies: + type-detect "4.0.8" + +"@types/babel__core@^7.1.0": + version "7.1.6" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.6.tgz#16ff42a5ae203c9af1c6e190ed1f30f83207b610" + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.6.1" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.1.tgz#4901767b397e8711aeb99df8d396d7ba7b7f0e04" + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.0.2.tgz#4ff63d6b52eddac1de7b975a5223ed32ecea9307" + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": + version "7.0.9" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.0.9.tgz#be82fab304b141c3eee81a4ce3b034d0eba1590a" + dependencies: + "@babel/types" "^7.3.0" + +"@types/color-name@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" + +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" + +"@types/istanbul-lib-report@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686" + dependencies: + "@types/istanbul-lib-coverage" "*" + +"@types/istanbul-reports@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-1.1.1.tgz#7a8cbf6a406f36c8add871625b278eaf0b0d255a" + dependencies: + "@types/istanbul-lib-coverage" "*" + "@types/istanbul-lib-report" "*" + +"@types/stack-utils@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" + +"@types/yargs-parser@*": + version "15.0.0" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d" + +"@types/yargs@^15.0.0": + version "15.0.4" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.4.tgz#7e5d0f8ca25e9d5849f2ea443cf7c402decd8299" + dependencies: + "@types/yargs-parser" "*" + +abab@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.0.tgz#aba0ab4c5eee2d4c79d3487d85450fb2376ebb0f" + +acorn-globals@^4.3.2: + version "4.3.4" + resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-4.3.4.tgz#9fa1926addc11c97308c4e66d7add0d40c3272e7" + dependencies: + acorn "^6.0.1" + acorn-walk "^6.0.1" + +acorn-walk@^6.0.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-6.1.1.tgz#d363b66f5fac5f018ff9c3a1e7b6f8e310cc3913" + +acorn@^6.0.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f" + +acorn@^7.1.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.1.tgz#e35668de0b402f359de515c5482a1ab9f89a69bf" + +ajv@^6.5.5: + version "6.10.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.0.tgz#90d0d54439da587cd7e843bfb7045f50bd22bdf1" + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-escapes@^4.2.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.1.tgz#a5c47cc43181f1f38ffd7076837700d395522a61" + dependencies: + type-fest "^0.11.0" + +ansi-regex@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" + +ansi-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" + dependencies: + "@types/color-name" "^1.1.1" + color-convert "^2.0.1" + +anymatch@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" + dependencies: + micromatch "^3.1.4" + normalize-path "^2.1.1" + +anymatch@^3.0.3: + version "3.1.1" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + dependencies: + sprintf-js "~1.0.2" + +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + +arr-flatten@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + +array-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93" + +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + +asn1@~0.2.3: + version "0.2.4" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" + dependencies: + safer-buffer "~2.1.0" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + +astral-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + +atob@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + +aws4@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" + +babel-jest@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-25.1.0.tgz#206093ac380a4b78c4404a05b3277391278f80fb" + dependencies: + "@jest/transform" "^25.1.0" + "@jest/types" "^25.1.0" + "@types/babel__core" "^7.1.0" + babel-plugin-istanbul "^6.0.0" + babel-preset-jest "^25.1.0" + chalk "^3.0.0" + slash "^3.0.0" + +babel-plugin-istanbul@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.0.0.tgz#e159ccdc9af95e0b570c75b4573b7c34d671d765" + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-instrument "^4.0.0" + test-exclude "^6.0.0" + +babel-plugin-jest-hoist@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-25.1.0.tgz#fb62d7b3b53eb36c97d1bc7fec2072f9bd115981" + dependencies: + "@types/babel__traverse" "^7.0.6" + +babel-preset-jest@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-25.1.0.tgz#d0aebfebb2177a21cde710996fce8486d34f1d33" + dependencies: + "@babel/plugin-syntax-bigint" "^7.0.0" + "@babel/plugin-syntax-object-rest-spread" "^7.0.0" + babel-plugin-jest-hoist "^25.1.0" + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + dependencies: + tweetnacl "^0.14.3" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +braces@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + dependencies: + fill-range "^7.0.1" + +browser-process-hrtime@^0.1.2: + version "0.1.3" + resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz#616f00faef1df7ec1b5bf9cfe2bdc3170f26c7b4" + +browser-resolve@^1.11.3: + version "1.11.3" + resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-1.11.3.tgz#9b7cbb3d0f510e4cb86bdbd796124d28b5890af6" + dependencies: + resolve "1.1.7" + +bser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.0.0.tgz#9ac78d3ed5d915804fd87acb158bc797147a1719" + dependencies: + node-int64 "^0.4.0" + +buffer-from@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" + +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + dependencies: + collection-visit "^1.0.0" + component-emitter "^1.2.1" + get-value "^2.0.6" + has-value "^1.0.0" + isobject "^3.0.1" + set-value "^2.0.0" + to-object-path "^0.3.0" + union-value "^1.0.0" + unset-value "^1.0.0" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + +camelcase@^5.0.0, camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + +capture-exit@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-2.0.0.tgz#fb953bfaebeb781f62898239dabb426d08a509a4" + dependencies: + rsvp "^4.8.4" + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + +chalk@^2.0.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + +collect-v8-coverage@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.0.tgz#150ee634ac3650b71d9c985eb7f608942334feb1" + +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + +combined-stream@^1.0.6, combined-stream@~1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.7.tgz#2d1d24317afb8abe95d6d2c0b07b57813539d828" + dependencies: + delayed-stream "~1.0.0" + +component-emitter@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + +convert-source-map@^1.4.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" + dependencies: + safe-buffer "~5.1.1" + +convert-source-map@^1.6.0, convert-source-map@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" + dependencies: + safe-buffer "~5.1.1" + +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + +core-js@^1.0.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" + +core-util-is@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + +cross-spawn@^6.0.0: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + +cross-spawn@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.1.tgz#0ab56286e0f7c24e153d04cc2aa027e43a9a5d14" + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +cssom@^0.4.1: + version "0.4.4" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" + +cssom@~0.3.6: + version "0.3.8" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" + +cssstyle@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-2.2.0.tgz#e4c44debccd6b7911ed617a4395e5754bba59992" + dependencies: + cssom "~0.3.6" + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + dependencies: + assert-plus "^1.0.0" + +data-urls@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-1.1.0.tgz#15ee0582baa5e22bb59c77140da8f9c76963bbfe" + dependencies: + abab "^2.0.0" + whatwg-mimetype "^2.2.0" + whatwg-url "^7.0.0" + +debug@^2.2.0, debug@^2.3.3: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + dependencies: + ms "2.0.0" + +debug@^4.1.0, debug@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + dependencies: + ms "^2.1.1" + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + +deep-is@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" + +define-properties@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" + dependencies: + object-keys "^1.0.12" + +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + +detect-newline@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" + +diff-sequences@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-25.1.0.tgz#fd29a46f1c913fd66c22645dc75bffbe43051f32" + +domexception@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" + dependencies: + webidl-conversions "^4.0.2" + +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + +encoding@^0.1.11: + version "0.1.12" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" + dependencies: + iconv-lite "~0.4.13" + +end-of-stream@^1.1.0: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + dependencies: + once "^1.4.0" + +es-abstract@^1.5.1: + version "1.13.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9" + dependencies: + es-to-primitive "^1.2.0" + function-bind "^1.1.1" + has "^1.0.3" + is-callable "^1.1.4" + is-regex "^1.0.4" + object-keys "^1.0.12" + +es-to-primitive@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.0.tgz#edf72478033456e8dda8ef09e00ad9650707f377" + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + +escodegen@^1.11.1: + version "1.14.1" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.1.tgz#ba01d0c8278b5e95a9a45350142026659027a457" + dependencies: + esprima "^4.0.1" + estraverse "^4.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + +esprima@^4.0.0, esprima@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + +estraverse@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" + +esutils@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" + +exec-sh@^0.3.2: + version "0.3.4" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.4.tgz#3a018ceb526cc6f6df2bb504b2bfe8e3a4934ec5" + +execa@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + dependencies: + cross-spawn "^6.0.0" + get-stream "^4.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +execa@^3.2.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-3.4.0.tgz#c08ed4550ef65d858fac269ffc8572446f37eb89" + dependencies: + cross-spawn "^7.0.0" + get-stream "^5.0.0" + human-signals "^1.1.1" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.0" + onetime "^5.1.0" + p-finally "^2.0.0" + signal-exit "^3.0.2" + strip-final-newline "^2.0.0" + +exit@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" + +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + dependencies: + debug "^2.3.3" + define-property "^0.2.5" + extend-shallow "^2.0.1" + posix-character-classes "^0.1.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +expect@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-25.1.0.tgz#7e8d7b06a53f7d66ec927278db3304254ee683ee" + dependencies: + "@jest/types" "^25.1.0" + ansi-styles "^4.0.0" + jest-get-type "^25.1.0" + jest-matcher-utils "^25.1.0" + jest-message-util "^25.1.0" + jest-regex-util "^25.1.0" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +extend@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + +extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + dependencies: + array-unique "^0.3.2" + define-property "^1.0.0" + expand-brackets "^2.1.4" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +extsprintf@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + +extsprintf@^1.2.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" + +fast-deep-equal@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" + +fast-json-stable-stringify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" + +fast-levenshtein@~2.0.4: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + +fb-watchman@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58" + dependencies: + bser "^2.0.0" + +fbjs@^0.8.16: + version "0.8.17" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.18" + +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + dependencies: + to-regex-range "^5.0.1" + +find-up@^4.0.0, find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + +form-data@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + dependencies: + map-cache "^0.2.2" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + +fsevents@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + +gensync@^1.0.0-beta.1: + version "1.0.0-beta.1" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269" + +get-caller-file@^2.0.1: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + +get-stream@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + dependencies: + pump "^3.0.0" + +get-stream@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.1.0.tgz#01203cdc92597f9b909067c3e656cc1f4d3c4dc9" + dependencies: + pump "^3.0.0" + +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + dependencies: + assert-plus "^1.0.0" + +glob@^7.1.1, glob@^7.1.2, glob@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^7.1.4: + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + +graceful-fs@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423" + +growly@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" + +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + +har-validator@~5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080" + dependencies: + ajv "^6.5.5" + har-schema "^2.0.0" + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + +has-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44" + +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + +has@^1.0.1, has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + dependencies: + function-bind "^1.1.1" + +html-encoding-sniffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz#e70d84b94da53aa375e11fe3a351be6642ca46f8" + dependencies: + whatwg-encoding "^1.0.1" + +html-escaper@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.1.tgz#beed86b5d2b921e92533aa11bce6d8e3b583dee7" + +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +human-signals@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" + +iconv-lite@0.4.24, iconv-lite@~0.4.13: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + dependencies: + safer-buffer ">= 2.1.2 < 3" + +import-local@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.0.2.tgz#a8cfd0431d1de4a2199703d003e3e62364fa6db6" + dependencies: + pkg-dir "^4.2.0" + resolve-cwd "^3.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + +ip-regex@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" + +is-accessor-descriptor@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" + dependencies: + kind-of "^3.0.2" + +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + dependencies: + kind-of "^6.0.0" + +is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + +is-callable@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75" + +is-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + dependencies: + ci-info "^2.0.0" + +is-data-descriptor@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" + dependencies: + kind-of "^3.0.2" + +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + dependencies: + kind-of "^6.0.0" + +is-date-object@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16" + +is-descriptor@^0.1.0: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" + dependencies: + is-accessor-descriptor "^0.1.6" + is-data-descriptor "^0.1.4" + kind-of "^5.0.0" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" + dependencies: + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" + +is-extendable@^0.1.0, is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + dependencies: + is-plain-object "^2.0.4" + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + +is-generator-fn@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" + +is-number@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" + dependencies: + kind-of "^3.0.2" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + +is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + dependencies: + isobject "^3.0.1" + +is-regex@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" + dependencies: + has "^1.0.1" + +is-stream@^1.0.1, is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + +is-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" + +is-symbol@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.2.tgz#a055f6ae57192caee329e7a860118b497a950f38" + dependencies: + has-symbols "^1.0.0" + +is-typedarray@^1.0.0, is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + +is-wsl@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.1.1.tgz#4a1c152d429df3d441669498e2486d3596ebaf1d" + +isarray@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + dependencies: + isarray "1.0.0" + +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + +isomorphic-fetch@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" + dependencies: + node-fetch "^1.0.1" + whatwg-fetch ">=0.10.0" + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + +istanbul-lib-coverage@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz#f5944a37c70b550b02a78a5c3b2055b280cec8ec" + +istanbul-lib-instrument@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.1.tgz#61f13ac2c96cfefb076fe7131156cc05907874e6" + dependencies: + "@babel/core" "^7.7.5" + "@babel/parser" "^7.7.5" + "@babel/template" "^7.7.4" + "@babel/traverse" "^7.7.4" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-coverage "^3.0.0" + semver "^6.3.0" + +istanbul-lib-report@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#7518fe52ea44de372f460a76b5ecda9ffb73d8a6" + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^3.0.0" + supports-color "^7.1.0" + +istanbul-lib-source-maps@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.0.tgz#75743ce6d96bb86dc7ee4352cf6366a23f0b1ad9" + dependencies: + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + source-map "^0.6.1" + +istanbul-reports@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.0.0.tgz#d4d16d035db99581b6194e119bbf36c963c5eb70" + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +jest-changed-files@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-25.1.0.tgz#73dae9a7d9949fdfa5c278438ce8f2ff3ec78131" + dependencies: + "@jest/types" "^25.1.0" + execa "^3.2.0" + throat "^5.0.0" + +jest-cli@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-25.1.0.tgz#75f0b09cf6c4f39360906bf78d580be1048e4372" + dependencies: + "@jest/core" "^25.1.0" + "@jest/test-result" "^25.1.0" + "@jest/types" "^25.1.0" + chalk "^3.0.0" + exit "^0.1.2" + import-local "^3.0.2" + is-ci "^2.0.0" + jest-config "^25.1.0" + jest-util "^25.1.0" + jest-validate "^25.1.0" + prompts "^2.0.1" + realpath-native "^1.1.0" + yargs "^15.0.0" + +jest-config@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-25.1.0.tgz#d114e4778c045d3ef239452213b7ad3ec1cbea90" + dependencies: + "@babel/core" "^7.1.0" + "@jest/test-sequencer" "^25.1.0" + "@jest/types" "^25.1.0" + babel-jest "^25.1.0" + chalk "^3.0.0" + glob "^7.1.1" + jest-environment-jsdom "^25.1.0" + jest-environment-node "^25.1.0" + jest-get-type "^25.1.0" + jest-jasmine2 "^25.1.0" + jest-regex-util "^25.1.0" + jest-resolve "^25.1.0" + jest-util "^25.1.0" + jest-validate "^25.1.0" + micromatch "^4.0.2" + pretty-format "^25.1.0" + realpath-native "^1.1.0" + +jest-diff@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-25.1.0.tgz#58b827e63edea1bc80c1de952b80cec9ac50e1ad" + dependencies: + chalk "^3.0.0" + diff-sequences "^25.1.0" + jest-get-type "^25.1.0" + pretty-format "^25.1.0" + +jest-docblock@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-25.1.0.tgz#0f44bea3d6ca6dfc38373d465b347c8818eccb64" + dependencies: + detect-newline "^3.0.0" + +jest-each@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-25.1.0.tgz#a6b260992bdf451c2d64a0ccbb3ac25e9b44c26a" + dependencies: + "@jest/types" "^25.1.0" + chalk "^3.0.0" + jest-get-type "^25.1.0" + jest-util "^25.1.0" + pretty-format "^25.1.0" + +jest-environment-jsdom@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-25.1.0.tgz#6777ab8b3e90fd076801efd3bff8e98694ab43c3" + dependencies: + "@jest/environment" "^25.1.0" + "@jest/fake-timers" "^25.1.0" + "@jest/types" "^25.1.0" + jest-mock "^25.1.0" + jest-util "^25.1.0" + jsdom "^15.1.1" + +jest-environment-node@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-25.1.0.tgz#797bd89b378cf0bd794dc8e3dca6ef21126776db" + dependencies: + "@jest/environment" "^25.1.0" + "@jest/fake-timers" "^25.1.0" + "@jest/types" "^25.1.0" + jest-mock "^25.1.0" + jest-util "^25.1.0" + +jest-get-type@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-25.1.0.tgz#1cfe5fc34f148dc3a8a3b7275f6b9ce9e2e8a876" + +jest-haste-map@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-25.1.0.tgz#ae12163d284f19906260aa51fd405b5b2e5a4ad3" + dependencies: + "@jest/types" "^25.1.0" + anymatch "^3.0.3" + fb-watchman "^2.0.0" + graceful-fs "^4.2.3" + jest-serializer "^25.1.0" + jest-util "^25.1.0" + jest-worker "^25.1.0" + micromatch "^4.0.2" + sane "^4.0.3" + walker "^1.0.7" + optionalDependencies: + fsevents "^2.1.2" + +jest-jasmine2@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-25.1.0.tgz#681b59158a430f08d5d0c1cce4f01353e4b48137" + dependencies: + "@babel/traverse" "^7.1.0" + "@jest/environment" "^25.1.0" + "@jest/source-map" "^25.1.0" + "@jest/test-result" "^25.1.0" + "@jest/types" "^25.1.0" + chalk "^3.0.0" + co "^4.6.0" + expect "^25.1.0" + is-generator-fn "^2.0.0" + jest-each "^25.1.0" + jest-matcher-utils "^25.1.0" + jest-message-util "^25.1.0" + jest-runtime "^25.1.0" + jest-snapshot "^25.1.0" + jest-util "^25.1.0" + pretty-format "^25.1.0" + throat "^5.0.0" + +jest-leak-detector@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-25.1.0.tgz#ed6872d15aa1c72c0732d01bd073dacc7c38b5c6" + dependencies: + jest-get-type "^25.1.0" + pretty-format "^25.1.0" + +jest-matcher-utils@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-25.1.0.tgz#fa5996c45c7193a3c24e73066fc14acdee020220" + dependencies: + chalk "^3.0.0" + jest-diff "^25.1.0" + jest-get-type "^25.1.0" + pretty-format "^25.1.0" + +jest-message-util@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-25.1.0.tgz#702a9a5cb05c144b9aa73f06e17faa219389845e" + dependencies: + "@babel/code-frame" "^7.0.0" + "@jest/test-result" "^25.1.0" + "@jest/types" "^25.1.0" + "@types/stack-utils" "^1.0.1" + chalk "^3.0.0" + micromatch "^4.0.2" + slash "^3.0.0" + stack-utils "^1.0.1" + +jest-mock@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-25.1.0.tgz#411d549e1b326b7350b2e97303a64715c28615fd" + dependencies: + "@jest/types" "^25.1.0" + +jest-pnp-resolver@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.1.tgz#ecdae604c077a7fbc70defb6d517c3c1c898923a" + +jest-regex-util@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-25.1.0.tgz#efaf75914267741838e01de24da07b2192d16d87" + +jest-resolve-dependencies@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-25.1.0.tgz#8a1789ec64eb6aaa77fd579a1066a783437e70d2" + dependencies: + "@jest/types" "^25.1.0" + jest-regex-util "^25.1.0" + jest-snapshot "^25.1.0" + +jest-resolve@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-25.1.0.tgz#23d8b6a4892362baf2662877c66aa241fa2eaea3" + dependencies: + "@jest/types" "^25.1.0" + browser-resolve "^1.11.3" + chalk "^3.0.0" + jest-pnp-resolver "^1.2.1" + realpath-native "^1.1.0" + +jest-runner@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-25.1.0.tgz#fef433a4d42c89ab0a6b6b268e4a4fbe6b26e812" + dependencies: + "@jest/console" "^25.1.0" + "@jest/environment" "^25.1.0" + "@jest/test-result" "^25.1.0" + "@jest/types" "^25.1.0" + chalk "^3.0.0" + exit "^0.1.2" + graceful-fs "^4.2.3" + jest-config "^25.1.0" + jest-docblock "^25.1.0" + jest-haste-map "^25.1.0" + jest-jasmine2 "^25.1.0" + jest-leak-detector "^25.1.0" + jest-message-util "^25.1.0" + jest-resolve "^25.1.0" + jest-runtime "^25.1.0" + jest-util "^25.1.0" + jest-worker "^25.1.0" + source-map-support "^0.5.6" + throat "^5.0.0" + +jest-runtime@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-25.1.0.tgz#02683218f2f95aad0f2ec1c9cdb28c1dc0ec0314" + dependencies: + "@jest/console" "^25.1.0" + "@jest/environment" "^25.1.0" + "@jest/source-map" "^25.1.0" + "@jest/test-result" "^25.1.0" + "@jest/transform" "^25.1.0" + "@jest/types" "^25.1.0" + "@types/yargs" "^15.0.0" + chalk "^3.0.0" + collect-v8-coverage "^1.0.0" + exit "^0.1.2" + glob "^7.1.3" + graceful-fs "^4.2.3" + jest-config "^25.1.0" + jest-haste-map "^25.1.0" + jest-message-util "^25.1.0" + jest-mock "^25.1.0" + jest-regex-util "^25.1.0" + jest-resolve "^25.1.0" + jest-snapshot "^25.1.0" + jest-util "^25.1.0" + jest-validate "^25.1.0" + realpath-native "^1.1.0" + slash "^3.0.0" + strip-bom "^4.0.0" + yargs "^15.0.0" + +jest-serializer@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-25.1.0.tgz#73096ba90e07d19dec4a0c1dd89c355e2f129e5d" + +jest-snapshot@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-25.1.0.tgz#d5880bd4b31faea100454608e15f8d77b9d221d9" + dependencies: + "@babel/types" "^7.0.0" + "@jest/types" "^25.1.0" + chalk "^3.0.0" + expect "^25.1.0" + jest-diff "^25.1.0" + jest-get-type "^25.1.0" + jest-matcher-utils "^25.1.0" + jest-message-util "^25.1.0" + jest-resolve "^25.1.0" + mkdirp "^0.5.1" + natural-compare "^1.4.0" + pretty-format "^25.1.0" + semver "^7.1.1" + +jest-util@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-25.1.0.tgz#7bc56f7b2abd534910e9fa252692f50624c897d9" + dependencies: + "@jest/types" "^25.1.0" + chalk "^3.0.0" + is-ci "^2.0.0" + mkdirp "^0.5.1" + +jest-validate@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-25.1.0.tgz#1469fa19f627bb0a9a98e289f3e9ab6a668c732a" + dependencies: + "@jest/types" "^25.1.0" + camelcase "^5.3.1" + chalk "^3.0.0" + jest-get-type "^25.1.0" + leven "^3.1.0" + pretty-format "^25.1.0" + +jest-watcher@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-25.1.0.tgz#97cb4a937f676f64c9fad2d07b824c56808e9806" + dependencies: + "@jest/test-result" "^25.1.0" + "@jest/types" "^25.1.0" + ansi-escapes "^4.2.1" + chalk "^3.0.0" + jest-util "^25.1.0" + string-length "^3.1.0" + +jest-worker@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-25.1.0.tgz#75d038bad6fdf58eba0d2ec1835856c497e3907a" + dependencies: + merge-stream "^2.0.0" + supports-color "^7.0.0" + +jest@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/jest/-/jest-25.1.0.tgz#b85ef1ddba2fdb00d295deebbd13567106d35be9" + dependencies: + "@jest/core" "^25.1.0" + import-local "^3.0.2" + jest-cli "^25.1.0" + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + +js-yaml@^3.13.1: + version "3.13.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + +jsdom@^15.1.1: + version "15.2.1" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-15.2.1.tgz#d2feb1aef7183f86be521b8c6833ff5296d07ec5" + dependencies: + abab "^2.0.0" + acorn "^7.1.0" + acorn-globals "^4.3.2" + array-equal "^1.0.0" + cssom "^0.4.1" + cssstyle "^2.0.0" + data-urls "^1.1.0" + domexception "^1.0.1" + escodegen "^1.11.1" + html-encoding-sniffer "^1.0.2" + nwsapi "^2.2.0" + parse5 "5.1.0" + pn "^1.1.0" + request "^2.88.0" + request-promise-native "^1.0.7" + saxes "^3.1.9" + symbol-tree "^3.2.2" + tough-cookie "^3.0.1" + w3c-hr-time "^1.0.1" + w3c-xmlserializer "^1.1.2" + webidl-conversions "^4.0.2" + whatwg-encoding "^1.0.5" + whatwg-mimetype "^2.3.0" + whatwg-url "^7.0.0" + ws "^7.0.0" + xml-name-validator "^3.0.0" + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + +json-schema@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + +json5@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.2.tgz#43ef1f0af9835dd624751a6b7fa48874fb2d608e" + dependencies: + minimist "^1.2.5" + +jsprim@^1.2.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.2.3" + verror "1.10.0" + +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + dependencies: + is-buffer "^1.1.5" + +kind-of@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" + +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + +levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + dependencies: + p-locate "^4.1.0" + +lodash.sortby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" + +lodash@^4.17.13, lodash@^4.17.15: + version "4.17.15" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" + +lolex@^5.0.0: + version "5.1.2" + resolved "https://registry.yarnpkg.com/lolex/-/lolex-5.1.2.tgz#953694d098ce7c07bc5ed6d0e42bc6c0c6d5a367" + dependencies: + "@sinonjs/commons" "^1.7.0" + +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +make-dir@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.0.2.tgz#04a1acbf22221e1d6ef43559f43e05a90dbb4392" + dependencies: + semver "^6.0.0" + +makeerror@1.0.x: + version "1.0.11" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" + dependencies: + tmpl "1.0.x" + +map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + dependencies: + object-visit "^1.0.0" + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + +micromatch@^3.1.4: + version "3.1.10" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.1" + define-property "^2.0.2" + extend-shallow "^3.0.2" + extglob "^2.0.4" + fragment-cache "^0.2.1" + kind-of "^6.0.2" + nanomatch "^1.2.9" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.2" + +micromatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" + dependencies: + braces "^3.0.1" + picomatch "^2.0.5" + +mime-db@~1.38.0: + version "1.38.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.38.0.tgz#1a2aab16da9eb167b49c6e4df2d9c68d63d8e2ad" + +mime-types@^2.1.12, mime-types@~2.1.19: + version "2.1.22" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.22.tgz#fe6b355a190926ab7698c9a0556a11199b2199bd" + dependencies: + mime-db "~1.38.0" + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + +minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + dependencies: + brace-expansion "^1.1.7" + +minimist@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + +minimist@^1.1.1, minimist@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" + +minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + +mixin-deep@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +mkdirp@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" + dependencies: + minimist "0.0.8" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + +ms@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" + +nanomatch@^1.2.9: + version "1.2.13" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^2.0.2" + extend-shallow "^3.0.2" + fragment-cache "^0.2.1" + is-windows "^1.0.2" + kind-of "^6.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + +node-fetch@^1.0.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + +node-modules-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" + +node-notifier@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-6.0.0.tgz#cea319e06baa16deec8ce5cd7f133c4a46b68e12" + dependencies: + growly "^1.3.0" + is-wsl "^2.1.1" + semver "^6.3.0" + shellwords "^0.1.1" + which "^1.3.1" + +normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + dependencies: + remove-trailing-separator "^1.0.1" + +normalize-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + dependencies: + path-key "^2.0.0" + +npm-run-path@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + dependencies: + path-key "^3.0.0" + +nwsapi@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7" + +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + +object-assign@^4.1.0, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + +object-keys@^1.0.12: + version "1.1.0" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.0.tgz#11bd22348dd2e096a045ab06f6c85bcc340fa032" + +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + dependencies: + isobject "^3.0.0" + +object.getownpropertydescriptors@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz#8758c846f5b407adab0f236e0986f14b051caa16" + dependencies: + define-properties "^1.1.2" + es-abstract "^1.5.1" + +object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + dependencies: + isobject "^3.0.1" + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + dependencies: + wrappy "1" + +onetime@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.0.tgz#fff0f3c91617fe62bb50189636e99ac8a6df7be5" + dependencies: + mimic-fn "^2.1.0" + +optionator@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.4" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + wordwrap "~1.0.0" + +p-each-series@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.1.0.tgz#961c8dd3f195ea96c747e636b262b800a6b1af48" + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + +p-finally@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-2.0.1.tgz#bd6fcaa9c559a096b680806f4d657b3f0f240561" + +p-limit@^2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.2.tgz#61279b67721f5287aa1c13a9a7fbbc48c9291b1e" + dependencies: + p-try "^2.0.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + dependencies: + p-limit "^2.2.0" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + +parse5@5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2" + +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + +path-key@^2.0.0, path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + +path-parse@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + +picomatch@^2.0.4, picomatch@^2.0.5: + version "2.2.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" + +pirates@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.1.tgz#643a92caf894566f91b2b986d2c66950a8e2fb87" + dependencies: + node-modules-regexp "^1.0.0" + +pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + dependencies: + find-up "^4.0.0" + +pn@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" + +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + +pretty-format@^25.1.0: + version "25.1.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-25.1.0.tgz#ed869bdaec1356fc5ae45de045e2c8ec7b07b0c8" + dependencies: + "@jest/types" "^25.1.0" + ansi-regex "^5.0.0" + ansi-styles "^4.0.0" + react-is "^16.12.0" + +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + dependencies: + asap "~2.0.3" + +prompts@^2.0.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.3.2.tgz#480572d89ecf39566d2bd3fe2c9fccb7c4c0b068" + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.4" + +prop-types@^15.6.0: + version "15.7.2" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.8.1" + +psl@^1.1.28: + version "1.1.31" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.31.tgz#e9aa86d0101b5b105cbe93ac6b784cd547276184" + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +punycode@^2.1.0, punycode@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + +qs@~6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + +react-dom@16.4.1: + version "16.4.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.4.1.tgz#7f8b0223b3a5fbe205116c56deb85de32685dad6" + dependencies: + fbjs "^0.8.16" + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.0" + +react-is@^16.12.0: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + +react-is@^16.4.1, react-is@^16.8.1: + version "16.8.4" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.4.tgz#90f336a68c3a29a096a3d648ab80e87ec61482a2" + +react-test-renderer@16.4.1: + version "16.4.1" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.4.1.tgz#f2fb30c2c7b517db6e5b10ed20bb6b0a7ccd8d70" + dependencies: + fbjs "^0.8.16" + object-assign "^4.1.1" + prop-types "^15.6.0" + react-is "^16.4.1" + +react@16.4.1: + version "16.4.1" + resolved "https://registry.yarnpkg.com/react/-/react-16.4.1.tgz#de51ba5764b5dbcd1f9079037b862bd26b82fe32" + dependencies: + fbjs "^0.8.16" + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.0" + +realpath-native@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c" + dependencies: + util.promisify "^1.0.0" + +regex-not@^1.0.0, regex-not@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + +repeat-element@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" + +repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + +request-promise-core@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.3.tgz#e9a3c081b51380dfea677336061fea879a829ee9" + dependencies: + lodash "^4.17.15" + +request-promise-native@^1.0.7: + version "1.0.8" + resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.8.tgz#a455b960b826e44e2bf8999af64dff2bfe58cb36" + dependencies: + request-promise-core "1.1.3" + stealthy-require "^1.1.1" + tough-cookie "^2.3.3" + +request@^2.88.0: + version "2.88.2" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.3" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.5.0" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + +resolve-cwd@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" + dependencies: + resolve-from "^5.0.0" + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + +resolve-url@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + +resolve@1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" + +resolve@^1.3.2: + version "1.15.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.1.tgz#27bdcdeffeaf2d6244b95bb0f9f4b4653451f3e8" + dependencies: + path-parse "^1.0.6" + +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + +rimraf@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + dependencies: + glob "^7.1.3" + +rsvp@^4.8.4: + version "4.8.5" + resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" + +safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + dependencies: + ret "~0.1.10" + +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + +sane@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/sane/-/sane-4.1.0.tgz#ed881fd922733a6c461bc189dc2b6c006f3ffded" + dependencies: + "@cnakazawa/watch" "^1.0.3" + anymatch "^2.0.0" + capture-exit "^2.0.0" + exec-sh "^0.3.2" + execa "^1.0.0" + fb-watchman "^2.0.0" + micromatch "^3.1.4" + minimist "^1.1.1" + walker "~1.0.5" + +saxes@^3.1.9: + version "3.1.11" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-3.1.11.tgz#d59d1fd332ec92ad98a2e0b2ee644702384b1c5b" + dependencies: + xmlchars "^2.1.1" + +semver@^5.4.1: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + +semver@^5.5.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004" + +semver@^6.0.0, semver@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + +semver@^7.1.1: + version "7.1.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.1.3.tgz#e4345ce73071c53f336445cfc19efb1c311df2a6" + +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + +set-value@^0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1" + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.1" + to-object-path "^0.3.0" + +set-value@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274" + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + dependencies: + shebang-regex "^1.0.0" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + +shellwords@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" + +signal-exit@^3.0.0, signal-exit@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" + +sisteransi@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" + dependencies: + base "^0.11.1" + debug "^2.2.0" + define-property "^0.2.5" + extend-shallow "^2.0.1" + map-cache "^0.2.2" + source-map "^0.5.6" + source-map-resolve "^0.5.0" + use "^3.1.0" + +source-map-resolve@^0.5.0: + version "0.5.2" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259" + dependencies: + atob "^2.1.1" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-support@^0.5.6: + version "0.5.11" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.11.tgz#efac2ce0800355d026326a0ca23e162aeac9a4e2" + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map-url@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" + +source-map@^0.5.0, source-map@^0.5.6: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + +source-map@^0.7.3: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + dependencies: + extend-shallow "^3.0.0" + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + +sshpk@^1.7.0: + version "1.16.1" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + +stack-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.2.tgz#33eba3897788558bebfc2db059dc158ec36cebb8" + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + +stealthy-require@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" + +string-length@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-3.1.0.tgz#107ef8c23456e187a8abd4a61162ff4ac6e25837" + dependencies: + astral-regex "^1.0.0" + strip-ansi "^5.2.0" + +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5" + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.0" + +strip-ansi@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + dependencies: + ansi-regex "^4.1.0" + +strip-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + dependencies: + ansi-regex "^5.0.0" + +strip-bom@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" + +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + dependencies: + has-flag "^3.0.0" + +supports-color@^7.0.0, supports-color@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" + dependencies: + has-flag "^4.0.0" + +supports-hyperlinks@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.1.0.tgz#f663df252af5f37c5d49bbd7eeefa9e0b9e59e47" + dependencies: + has-flag "^4.0.0" + supports-color "^7.0.0" + +symbol-tree@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6" + +terminal-link@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" + dependencies: + ansi-escapes "^4.2.1" + supports-hyperlinks "^2.0.0" + +test-exclude@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^7.1.4" + minimatch "^3.0.4" + +throat@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" + +tmpl@1.0.x: + version "1.0.4" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + dependencies: + kind-of "^3.0.2" + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + dependencies: + is-number "^7.0.0" + +to-regex@^3.0.1, to-regex@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" + dependencies: + define-property "^2.0.2" + extend-shallow "^3.0.2" + regex-not "^1.0.2" + safe-regex "^1.1.0" + +tough-cookie@^2.3.3, tough-cookie@~2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + dependencies: + psl "^1.1.28" + punycode "^2.1.1" + +tough-cookie@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-3.0.1.tgz#9df4f57e739c26930a018184887f4adb7dca73b2" + dependencies: + ip-regex "^2.1.0" + psl "^1.1.28" + punycode "^2.1.1" + +tr46@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" + dependencies: + punycode "^2.1.0" + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + dependencies: + prelude-ls "~1.1.2" + +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + +type-fest@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.11.0.tgz#97abf0872310fed88a5c466b25681576145e33f1" + +typedarray-to-buffer@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" + dependencies: + is-typedarray "^1.0.0" + +ua-parser-js@^0.7.18: + version "0.7.19" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.19.tgz#94151be4c0a7fb1d001af7022fdaca4642659e4b" + +union-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4" + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^0.4.3" + +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + +uri-js@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" + dependencies: + punycode "^2.1.0" + +urix@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + +use@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" + +util.promisify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.0.tgz#440f7165a459c9a16dc145eb8e72f35687097030" + dependencies: + define-properties "^1.1.2" + object.getownpropertydescriptors "^2.0.3" + +uuid@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" + +v8-to-istanbul@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-4.1.2.tgz#387d173be5383dbec209d21af033dcb892e3ac82" + dependencies: + "@types/istanbul-lib-coverage" "^2.0.1" + convert-source-map "^1.6.0" + source-map "^0.7.3" + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +w3c-hr-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz#82ac2bff63d950ea9e3189a58a65625fedf19045" + dependencies: + browser-process-hrtime "^0.1.2" + +w3c-xmlserializer@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-1.1.2.tgz#30485ca7d70a6fd052420a3d12fd90e6339ce794" + dependencies: + domexception "^1.0.1" + webidl-conversions "^4.0.2" + xml-name-validator "^3.0.0" + +walker@^1.0.7, walker@~1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb" + dependencies: + makeerror "1.0.x" + +webidl-conversions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" + +whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" + dependencies: + iconv-lite "0.4.24" + +whatwg-fetch@>=0.10.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" + +whatwg-mimetype@^2.2.0, whatwg-mimetype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" + +whatwg-url@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.0.0.tgz#fde926fa54a599f3adf82dff25a9f7be02dc6edd" + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + +which-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" + +which@^1.2.9, which@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + dependencies: + isexe "^2.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + dependencies: + isexe "^2.0.0" + +wordwrap@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + +write-file-atomic@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" + dependencies: + imurmurhash "^0.1.4" + is-typedarray "^1.0.0" + signal-exit "^3.0.2" + typedarray-to-buffer "^3.1.5" + +ws@^7.0.0: + version "7.2.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.3.tgz#a5411e1fb04d5ed0efee76d26d5c46d830c39b46" + +xml-name-validator@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" + +xmlchars@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + +y18n@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" + +yargs-parser@^18.1.1: + version "18.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.1.tgz#bf7407b915427fc760fcbbccc6c82b4f0ffcbd37" + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs@^15.0.0: + version "15.3.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.3.1.tgz#9505b472763963e54afe60148ad27a330818e98b" + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.1" diff --git a/devtools/client/framework/test/reload/.eslintrc.js b/devtools/client/framework/test/reload/.eslintrc.js new file mode 100644 index 0000000000..562c45bc96 --- /dev/null +++ b/devtools/client/framework/test/reload/.eslintrc.js @@ -0,0 +1,18 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +module.exports = { + globals: { + __dirname: true, + }, + overrides: [ + { + files: ["**/*.js"], + rules: { + "no-unused-vars": "off", + }, + }, + ], +}; diff --git a/devtools/client/framework/test/reload/README.md b/devtools/client/framework/test/reload/README.md new file mode 100644 index 0000000000..e8f71af039 --- /dev/null +++ b/devtools/client/framework/test/reload/README.md @@ -0,0 +1,4 @@ +# STEPS TO REBUILD THE BUNDLES FOR ALL VERSIONS + +1. yarn install +2. yarn webpack diff --git a/devtools/client/framework/test/reload/package.json b/devtools/client/framework/test/reload/package.json new file mode 100644 index 0000000000..a2c06ff28f --- /dev/null +++ b/devtools/client/framework/test/reload/package.json @@ -0,0 +1,12 @@ +{ + "name": "code-reload", + "version": "1.0.0", + "description": "Examples folders for testing sourcemps on code reload", + "main": "index.js", + "author": "Hubert B Manilla <hmanilla@mozilla.com>", + "license": "MIT", + "devDependencies": { + "webpack": "^5.75.0", + "webpack-cli": "^5.0.1" + } +} diff --git a/devtools/client/framework/test/reload/v1/code_bundle_reload.js b/devtools/client/framework/test/reload/v1/code_bundle_reload.js new file mode 100644 index 0000000000..bed88f7565 --- /dev/null +++ b/devtools/client/framework/test/reload/v1/code_bundle_reload.js @@ -0,0 +1,19 @@ +/******/ (() => { + // webpackBootstrap + /******/ "use strict"; + var __webpack_exports__ = {}; + /*!*****************************!*\ + !*** ./v1/code_reload_1.js ***! + \*****************************/ + /* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + + function f() { + console.log("The first version of the script"); + } + + f(); + + /******/ +})(); +//# sourceMappingURL=code_bundle_reload.js.map diff --git a/devtools/client/framework/test/reload/v1/code_bundle_reload.js.map b/devtools/client/framework/test/reload/v1/code_bundle_reload.js.map new file mode 100644 index 0000000000..d41c415820 --- /dev/null +++ b/devtools/client/framework/test/reload/v1/code_bundle_reload.js.map @@ -0,0 +1 @@ +{"version":3,"file":"v1/code_bundle_reload.js","mappings":";;;;;;AAAA;AACA;;AAEa;;AAEb;AACA;AACA;;AAEA","sources":["webpack://code-reload/./v1/code_reload_1.js"],"sourcesContent":["/* Any copyright is dedicated to the Public Domain.\n http://creativecommons.org/publicdomain/zero/1.0/ */\n\n\"use strict\";\n\nfunction f() {\n console.log(\"The first version of the script\");\n}\n\nf();\n"],"names":[],"sourceRoot":""}
\ No newline at end of file diff --git a/devtools/client/framework/test/reload/v1/code_reload_1.js b/devtools/client/framework/test/reload/v1/code_reload_1.js new file mode 100644 index 0000000000..0b91e3857a --- /dev/null +++ b/devtools/client/framework/test/reload/v1/code_reload_1.js @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function f() { + console.log("The first version of the script"); +} + +f(); diff --git a/devtools/client/framework/test/reload/v1/doc_reload.html b/devtools/client/framework/test/reload/v1/doc_reload.html new file mode 100644 index 0000000000..25df0d24b8 --- /dev/null +++ b/devtools/client/framework/test/reload/v1/doc_reload.html @@ -0,0 +1,15 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <script src="code_bundle_reload.js"></script> + <head> + <meta charset="utf-8"/> + <title>Empty test page 1</title> + </head> + + <body> + </body> + +</html> diff --git a/devtools/client/framework/test/reload/v2/code_bundle_reload.js b/devtools/client/framework/test/reload/v2/code_bundle_reload.js new file mode 100644 index 0000000000..c21166ba58 --- /dev/null +++ b/devtools/client/framework/test/reload/v2/code_bundle_reload.js @@ -0,0 +1,19 @@ +/******/ (() => { + // webpackBootstrap + /******/ "use strict"; + var __webpack_exports__ = {}; + /*!*****************************!*\ + !*** ./v2/code_reload_2.js ***! + \*****************************/ + /* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + + function f() { + console.log("The second version of the script"); + } + + f(); + + /******/ +})(); +//# sourceMappingURL=code_bundle_reload.js.map diff --git a/devtools/client/framework/test/reload/v2/code_bundle_reload.js.map b/devtools/client/framework/test/reload/v2/code_bundle_reload.js.map new file mode 100644 index 0000000000..d28bd1e30c --- /dev/null +++ b/devtools/client/framework/test/reload/v2/code_bundle_reload.js.map @@ -0,0 +1 @@ +{"version":3,"file":"v2/code_bundle_reload.js","mappings":";;;;;;AAAA;AACA;;AAEa;;AAEb;AACA;AACA;;AAEA","sources":["webpack://code-reload/./v2/code_reload_2.js"],"sourcesContent":["/* Any copyright is dedicated to the Public Domain.\n http://creativecommons.org/publicdomain/zero/1.0/ */\n\n\"use strict\";\n\nfunction f() {\n console.log(\"The second version of the script\");\n}\n\nf();\n"],"names":[],"sourceRoot":""}
\ No newline at end of file diff --git a/devtools/client/framework/test/reload/v2/code_reload_2.js b/devtools/client/framework/test/reload/v2/code_reload_2.js new file mode 100644 index 0000000000..f4690279b4 --- /dev/null +++ b/devtools/client/framework/test/reload/v2/code_reload_2.js @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function f() { + console.log("The second version of the script"); +} + +f(); diff --git a/devtools/client/framework/test/reload/v2/doc_reload.html b/devtools/client/framework/test/reload/v2/doc_reload.html new file mode 100644 index 0000000000..164e2cd26c --- /dev/null +++ b/devtools/client/framework/test/reload/v2/doc_reload.html @@ -0,0 +1,15 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <script src="code_bundle_reload.js"></script> + <head> + <meta charset="utf-8"/> + <title>Empty test page 2</title> + </head> + + <body> + </body> + +</html> diff --git a/devtools/client/framework/test/reload/webpack.config.js b/devtools/client/framework/test/reload/webpack.config.js new file mode 100644 index 0000000000..e26b42cd4c --- /dev/null +++ b/devtools/client/framework/test/reload/webpack.config.js @@ -0,0 +1,13 @@ +const path = require("path"); + +module.exports = [1, 2].map(version => { + return { + devtool: "source-map", + mode: "development", + entry: [path.join(__dirname, `/v${version}/code_reload_${version}.js`)], + output: { + path: __dirname, + filename: `v${version}/code_bundle_reload.js`, + }, + }; +}); diff --git a/devtools/client/framework/test/serviceworker.js b/devtools/client/framework/test/serviceworker.js new file mode 100644 index 0000000000..db1b339fe6 --- /dev/null +++ b/devtools/client/framework/test/serviceworker.js @@ -0,0 +1,4 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// empty service worker, always succeed! diff --git a/devtools/client/framework/test/sjs_cache_controle_header.sjs b/devtools/client/framework/test/sjs_cache_controle_header.sjs new file mode 100644 index 0000000000..af58a3fc89 --- /dev/null +++ b/devtools/client/framework/test/sjs_cache_controle_header.sjs @@ -0,0 +1,19 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* exported handleRequest */ + +"use strict"; + +// Simple server that writes a text response displaying the value of the +// cache-control header: +// - if the header is missing, the text will be `cache-control:` +// - if the header is available, the text will be `cache-control:${value}` +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=utf-8", false); + if (request.hasHeader("cache-control")) { + response.write(`cache-control:${request.getHeader("cache-control")}`); + } else { + response.write(`cache-control:`); + } +} diff --git a/devtools/client/framework/test/test_chrome_page.html b/devtools/client/framework/test/test_chrome_page.html new file mode 100644 index 0000000000..688b9de1d6 --- /dev/null +++ b/devtools/client/framework/test/test_chrome_page.html @@ -0,0 +1,9 @@ +<!doctype html> +<meta charset=utf-8> +<title>Chrome page</title> +<script> +// eslint-disable-next-line no-unused-vars +function inlineScript() { + console.log("foo"); +} +</script> diff --git a/devtools/client/framework/test/xpcshell/.eslintrc.js b/devtools/client/framework/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..7f6b62a9e5 --- /dev/null +++ b/devtools/client/framework/test/xpcshell/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + extends: "../../../../.eslintrc.xpcshell.js", +}; diff --git a/devtools/client/framework/test/xpcshell/test_tabs_absolute_order.js b/devtools/client/framework/test/xpcshell/test_tabs_absolute_order.js new file mode 100644 index 0000000000..23755d5e8d --- /dev/null +++ b/devtools/client/framework/test/xpcshell/test_tabs_absolute_order.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); + +const TEST_DATA = [ + { + description: "Test for no order in preference", + preferenceOrder: [], + currentTabsOrder: ["T1", "T2", "T3", "T4", "T5"], + dragTarget: "T1", + expectedOrder: ["T1", "T2", "T3", "T4", "T5"], + }, + { + description: "Test for drag a tab to left with hidden tab", + preferenceOrder: ["T1", "T2", "T3", "E1", "T4", "T5"], + currentTabsOrder: ["T1", "T2", "T4", "T3", "T5"], + dragTarget: "T4", + expectedOrder: ["T1", "T2", "T4", "T3", "E1", "T5"], + }, + { + description: "Test for drag a tab to right with hidden tab", + preferenceOrder: ["T1", "T2", "T3", "E1", "T4", "T5"], + currentTabsOrder: ["T1", "T3", "T4", "T2", "T5"], + dragTarget: "T2", + expectedOrder: ["T1", "T3", "E1", "T4", "T2", "T5"], + }, + { + description: + "Test for drag a tab to left end in case hidden tab was left end", + preferenceOrder: ["E1", "T1", "T2", "T3", "T4", "T5"], + currentTabsOrder: ["T4", "T1", "T2", "T3", "T5"], + dragTarget: "T4", + expectedOrder: ["E1", "T4", "T1", "T2", "T3", "T5"], + }, + { + description: + "Test for drag a tab to right end in case hidden tab was right end", + preferenceOrder: ["T1", "T2", "T3", "T4", "T5", "E1"], + currentTabsOrder: ["T2", "T3", "T4", "T5", "T1"], + dragTarget: "T1", + expectedOrder: ["T2", "T3", "T4", "T5", "E1", "T1"], + }, + { + description: "Test for multiple hidden tabs", + preferenceOrder: ["T1", "T2", "E1", "E2", "E3", "E4"], + currentTabsOrder: ["T2", "T1"], + dragTarget: "T1", + expectedOrder: ["T2", "E1", "E2", "E3", "E4", "T1"], + }, +]; + +function run_test() { + const { + toAbsoluteOrder, + } = require("resource://devtools/client/framework/toolbox-tabs-order-manager.js"); + + for (const { + description, + preferenceOrder, + currentTabsOrder, + dragTarget, + expectedOrder, + } of TEST_DATA) { + info(description); + const resultOrder = toAbsoluteOrder( + preferenceOrder, + currentTabsOrder, + dragTarget + ); + equal( + resultOrder.join(","), + expectedOrder.join(","), + "Result should be correct" + ); + } +} diff --git a/devtools/client/framework/test/xpcshell/xpcshell.toml b/devtools/client/framework/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..569a4b1e71 --- /dev/null +++ b/devtools/client/framework/test/xpcshell/xpcshell.toml @@ -0,0 +1,6 @@ +[DEFAULT] +tags = "devtools" +firefox-appdir = "browser" +skip-if = ["os == 'android'"] + +["test_tabs_absolute_order.js"] diff --git a/devtools/client/framework/toolbox-context-menu.js b/devtools/client/framework/toolbox-context-menu.js new file mode 100644 index 0000000000..b02aed14d0 --- /dev/null +++ b/devtools/client/framework/toolbox-context-menu.js @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Menu = require("resource://devtools/client/framework/menu.js"); +const MenuItem = require("resource://devtools/client/framework/menu-item.js"); + +// This WeakMap will be used to know if strings have already been loaded in a given +// window, which will be used as key. +const stringsLoaded = new WeakMap(); + +/** + * Lazily load strings for the edit menu. + */ +function loadEditMenuStrings(win) { + if (stringsLoaded.has(win)) { + return; + } + + if (win.MozXULElement) { + stringsLoaded.set(win, true); + win.MozXULElement.insertFTLIfNeeded("toolkit/global/textActions.ftl"); + } +} + +/** + * Return an 'edit' menu for a input field. This integrates directly + * with docshell commands to provide the right enabled state and editor + * functionality. + * + * You'll need to call menu.popup() yourself, this just returns the Menu instance. + * + * @param {Window} win parent window reference + * @param {String} id menu ID + * + * @returns {Menu} + */ +function createEditContextMenu(win, id) { + // Localized strings for the menu are loaded lazily. + loadEditMenuStrings(win); + + const docshell = win.docShell; + const menu = new Menu({ id }); + menu.append( + new MenuItem({ + id: "editmenu-undo", + l10nID: "text-action-undo", + disabled: !docshell.isCommandEnabled("cmd_undo"), + click: () => { + docshell.doCommand("cmd_undo"); + }, + }) + ); + menu.append( + new MenuItem({ + type: "separator", + }) + ); + menu.append( + new MenuItem({ + id: "editmenu-cut", + l10nID: "text-action-cut", + disabled: !docshell.isCommandEnabled("cmd_cut"), + click: () => { + docshell.doCommand("cmd_cut"); + }, + }) + ); + menu.append( + new MenuItem({ + id: "editmenu-copy", + l10nID: "text-action-copy", + disabled: !docshell.isCommandEnabled("cmd_copy"), + click: () => { + docshell.doCommand("cmd_copy"); + }, + }) + ); + menu.append( + new MenuItem({ + id: "editmenu-paste", + l10nID: "text-action-paste", + disabled: !docshell.isCommandEnabled("cmd_paste"), + click: () => { + docshell.doCommand("cmd_paste"); + }, + }) + ); + menu.append( + new MenuItem({ + id: "editmenu-delete", + l10nID: "text-action-delete", + disabled: !docshell.isCommandEnabled("cmd_delete"), + click: () => { + docshell.doCommand("cmd_delete"); + }, + }) + ); + menu.append( + new MenuItem({ + type: "separator", + }) + ); + menu.append( + new MenuItem({ + id: "editmenu-selectAll", + l10nID: "text-action-select-all", + disabled: !docshell.isCommandEnabled("cmd_selectAll"), + click: () => { + docshell.doCommand("cmd_selectAll"); + }, + }) + ); + return menu; +} + +module.exports.createEditContextMenu = createEditContextMenu; diff --git a/devtools/client/framework/toolbox-host-manager.js b/devtools/client/framework/toolbox-host-manager.js new file mode 100644 index 0000000000..6c1a0e645d --- /dev/null +++ b/devtools/client/framework/toolbox-host-manager.js @@ -0,0 +1,358 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); +const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +const { DOMHelpers } = require("resource://devtools/shared/dom-helpers.js"); + +// The min-width of toolbox and browser toolbox. +const WIDTH_CHEVRON_AND_MEATBALL = 50; +const WIDTH_CHEVRON_AND_MEATBALL_AND_CLOSE = 74; +const ZOOM_VALUE_PREF = "devtools.toolbox.zoomValue"; + +loader.lazyRequireGetter( + this, + "Toolbox", + "resource://devtools/client/framework/toolbox.js", + true +); +loader.lazyRequireGetter( + this, + "Hosts", + "resource://devtools/client/framework/toolbox-hosts.js", + true +); + +/** + * Implement a wrapper on the chrome side to setup a Toolbox within Firefox UI. + * + * This component handles iframe creation within Firefox, in which we are loading + * the toolbox document. Then both the chrome and the toolbox document communicate + * via "message" events. + * + * Messages sent by the toolbox to the chrome: + * - switch-host: + * Order to display the toolbox in another host (side, bottom, window, or the + * previously used one) + * - raise-host: + * Focus the tools + * - set-host-title: + * When using the window host, update the window title + * + * Messages sent by the chrome to the toolbox: + * - switched-host: + * The `switch-host` command sent by the toolbox is done + */ + +const LAST_HOST = "devtools.toolbox.host"; +const PREVIOUS_HOST = "devtools.toolbox.previousHost"; +let ID_COUNTER = 1; + +function ToolboxHostManager(commands, hostType, hostOptions) { + this.commands = commands; + + // When debugging a local tab, we keep a reference of the current tab into which the toolbox is displayed. + // This will only change from the descriptor's localTab when we start debugging popups (i.e. window.open). + this.currentTab = this.commands.descriptorFront.localTab; + + // Keep the previously instantiated Host for all tabs where we displayed the Toolbox. + // This will only be useful when we start debugging popups (i.e. window.open). + // This is used to re-use the previous host instance when we re-select the original tab + // we were debugging before the popup opened. + this.hostPerTab = new Map(); + + this.frameId = ID_COUNTER++; + + if (!hostType) { + hostType = Services.prefs.getCharPref(LAST_HOST); + if (!Hosts[hostType]) { + // If the preference value is unexpected, restore to the default value. + Services.prefs.clearUserPref(LAST_HOST); + hostType = Services.prefs.getCharPref(LAST_HOST); + } + } + this.eventController = new AbortController(); + this.host = this.createHost(hostType, hostOptions); + this.hostType = hostType; + this.setMinWidthWithZoom = this.setMinWidthWithZoom.bind(this); + this._onMessage = this._onMessage.bind(this); + Services.prefs.addObserver(ZOOM_VALUE_PREF, this.setMinWidthWithZoom); +} + +ToolboxHostManager.prototype = { + async create(toolId) { + await this.host.create(); + if (this.currentTab) { + this.hostPerTab.set(this.currentTab, this.host); + } + + this.host.frame.setAttribute("aria-label", L10N.getStr("toolbox.label")); + this.host.frame.ownerDocument.defaultView.addEventListener( + "message", + this._onMessage, + { signal: this.eventController.signal } + ); + + const toolbox = new Toolbox( + this.commands, + toolId, + this.host.type, + this.host.frame.contentWindow, + this.frameId + ); + toolbox.once("destroyed", this._onToolboxDestroyed.bind(this)); + + // Prevent reloading the toolbox when loading the tools in a tab + // (e.g. from about:debugging) + const location = this.host.frame.contentWindow.location; + if (!location.href.startsWith("about:devtools-toolbox")) { + this.host.frame.setAttribute("src", "about:devtools-toolbox"); + } + + this.setMinWidthWithZoom(); + return toolbox; + }, + + setMinWidthWithZoom() { + const zoomValue = parseFloat(Services.prefs.getCharPref(ZOOM_VALUE_PREF)); + + if (isNaN(zoomValue)) { + return; + } + + if ( + this.hostType === Toolbox.HostType.LEFT || + this.hostType === Toolbox.HostType.RIGHT + ) { + this.host.frame.style.minWidth = + WIDTH_CHEVRON_AND_MEATBALL_AND_CLOSE * zoomValue + "px"; + } else if ( + this.hostType === Toolbox.HostType.WINDOW || + this.hostType === Toolbox.HostType.PAGE || + this.hostType === Toolbox.HostType.BROWSERTOOLBOX + ) { + this.host.frame.style.minWidth = + WIDTH_CHEVRON_AND_MEATBALL * zoomValue + "px"; + } + }, + + _onToolboxDestroyed() { + // Delay self-destruction to let the debugger complete async destruction. + // Otherwise it throws when running browser_dbg-breakpoints-in-evaled-sources.js + // because the promise middleware delay each promise action using setTimeout... + DevToolsUtils.executeSoon(() => { + this.destroy(); + }); + }, + + _onMessage(event) { + if (!event.data) { + return; + } + const msg = event.data; + // Toolbox document is still chrome and disallow identifying message + // origin via event.source as it is null. So use a custom id. + if (msg.frameId != this.frameId) { + return; + } + switch (msg.name) { + case "switch-host": + this.switchHost(msg.hostType); + break; + case "switch-host-to-tab": + this.switchHostToTab(msg.tabBrowsingContextID); + break; + case "raise-host": + this.host.raise(); + this.postMessage({ + name: "host-raised", + }); + break; + case "set-host-title": + this.host.setTitle(msg.title); + break; + } + }, + + postMessage(data) { + const window = this.host.frame.contentWindow; + window.postMessage(data, "*"); + }, + + destroy() { + Services.prefs.removeObserver(ZOOM_VALUE_PREF, this.setMinWidthWithZoom); + this.eventController.abort(); + this.eventController = null; + this.destroyHost(); + // When we are debugging popup, we created host for each popup opened + // in some other tabs. Ensure destroying them here. + for (const host of this.hostPerTab.values()) { + host.destroy(); + } + this.hostPerTab.clear(); + this.host = null; + this.hostType = null; + this.commands = null; + }, + + /** + * Create a host object based on the given host type. + * + * Warning: bottom and sidebar hosts require that the toolbox target provides + * a reference to the attached tab. Not all Targets have a tab property - + * make sure you correctly mix and match hosts and targets. + * + * @param {string} hostType + * The host type of the new host object + * + * @return {Host} host + * The created host object + */ + createHost(hostType, options) { + if (!Hosts[hostType]) { + throw new Error("Unknown hostType: " + hostType); + } + const newHost = new Hosts[hostType](this.currentTab, options); + return newHost; + }, + + /** + * Migrate the toolbox to a new host, while keeping it fully functional. + * The toolbox's iframe will be moved as-is to the new host. + * + * @param {String} hostType + * The new type of host to spawn + * @param {Boolean} destroyPreviousHost + * Defaults to true. If false is passed, we will avoid destroying + * the previous host. This is helpful for popup debugging, + * where we migrate the toolbox between two tabs. In this scenario + * we are reusing previously instantiated hosts. This is especially + * useful when we close the current tab and have to have an + * already instantiated host to migrate to. If we don't have one, + * the toolbox iframe will already be destroyed before we have a chance + * to migrate it. + */ + async switchHost(hostType, destroyPreviousHost = true) { + if (hostType == "previous") { + // Switch to the last used host for the toolbox UI. + // This is determined by the devtools.toolbox.previousHost pref. + hostType = Services.prefs.getCharPref(PREVIOUS_HOST); + + // Handle the case where the previous host happens to match the current + // host. If so, switch to bottom if it's not already used, and right side if not. + if (hostType === this.hostType) { + if (hostType === Toolbox.HostType.BOTTOM) { + hostType = Toolbox.HostType.RIGHT; + } else { + hostType = Toolbox.HostType.BOTTOM; + } + } + } + const iframe = this.host.frame; + const newHost = this.createHost(hostType); + const newIframe = await newHost.create(); + + // Load a blank document in the host frame. The new iframe must have a valid + // document before using swapFrameLoaders(). + await new Promise(resolve => { + newIframe.setAttribute("src", "about:blank"); + DOMHelpers.onceDOMReady(newIframe.contentWindow, resolve); + }); + + // change toolbox document's parent to the new host + newIframe.swapFrameLoaders(iframe); + if (destroyPreviousHost) { + this.destroyHost(); + } + + if ( + this.hostType !== Toolbox.HostType.BROWSERTOOLBOX && + this.hostType !== Toolbox.HostType.PAGE + ) { + Services.prefs.setCharPref(PREVIOUS_HOST, this.hostType); + } + + this.host = newHost; + if (this.currentTab) { + this.hostPerTab.set(this.currentTab, newHost); + } + this.hostType = hostType; + this.host.setTitle(this.host.frame.contentWindow.document.title); + this.host.frame.ownerDocument.defaultView.addEventListener( + "message", + this._onMessage, + { signal: this.eventController.signal } + ); + + this.setMinWidthWithZoom(); + + if ( + hostType !== Toolbox.HostType.BROWSERTOOLBOX && + hostType !== Toolbox.HostType.PAGE + ) { + Services.prefs.setCharPref(LAST_HOST, hostType); + } + + // Tell the toolbox the host changed + this.postMessage({ + name: "switched-host", + hostType, + }); + }, + + /** + * When we are debugging popup, we are moving around the toolbox between original tab + * and popup tabs. This method will only move the host to a new tab, while + * keeping the same host type. + * + * @param {String} tabBrowsingContextID + * The ID of the browsing context of the tab we want to move to. + */ + async switchHostToTab(tabBrowsingContextID) { + const { gBrowser } = this.host.frame.ownerDocument.defaultView; + + const previousTab = this.currentTab; + const newTab = gBrowser.tabs.find( + tab => tab.linkedBrowser.browsingContext.id == tabBrowsingContextID + ); + // Note that newTab will be undefined when the popup opens in a new top level window. + if (newTab && newTab != previousTab) { + this.currentTab = newTab; + const newHost = this.hostPerTab.get(this.currentTab); + if (newHost) { + newHost.frame.swapFrameLoaders(this.host.frame); + this.host = newHost; + } else { + await this.switchHost(this.hostType, false); + } + previousTab.addEventListener( + "TabSelect", + event => { + this.switchHostToTab(event.target.linkedBrowser.browsingContext.id); + }, + { once: true, signal: this.eventController.signal } + ); + } + + this.postMessage({ + name: "switched-host-to-tab", + browsingContextID: tabBrowsingContextID, + }); + }, + + /** + * Destroy the current host, and remove event listeners from its frame. + * + * @return {promise} to be resolved when the host is destroyed. + */ + destroyHost() { + return this.host.destroy(); + }, +}; +exports.ToolboxHostManager = ToolboxHostManager; diff --git a/devtools/client/framework/toolbox-hosts.js b/devtools/client/framework/toolbox-hosts.js new file mode 100644 index 0000000000..59d113848f --- /dev/null +++ b/devtools/client/framework/toolbox-hosts.js @@ -0,0 +1,460 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +loader.lazyRequireGetter( + this, + "gDevToolsBrowser", + "resource://devtools/client/framework/devtools-browser.js", + true +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +/* A host should always allow this much space for the page to be displayed. + * There is also a min-height on the browser, but we still don't want to set + * frame.style.height to be larger than that, since it can cause problems with + * resizing the toolbox and panel layout. */ +const MIN_PAGE_SIZE = 25; + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +/** + * A toolbox host represents an object that contains a toolbox (e.g. the + * sidebar or a separate window). Any host object should implement the + * following functions: + * + * create() - create the UI + * destroy() - destroy the host's UI + */ + +/** + * Host object for the dock on the bottom of the browser + */ +function BottomHost(hostTab) { + this.hostTab = hostTab; + + EventEmitter.decorate(this); +} + +BottomHost.prototype = { + type: "bottom", + + heightPref: "devtools.toolbox.footer.height", + + /** + * Create a box at the bottom of the host tab. + */ + async create() { + await gDevToolsBrowser.loadBrowserStyleSheet(this.hostTab.ownerGlobal); + + const gBrowser = this.hostTab.ownerDocument.defaultView.gBrowser; + const ownerDocument = gBrowser.ownerDocument; + this._browserContainer = gBrowser.getBrowserContainer( + this.hostTab.linkedBrowser + ); + + this._splitter = ownerDocument.createXULElement("splitter"); + this._splitter.setAttribute("class", "devtools-horizontal-splitter"); + this._splitter.setAttribute("resizebefore", "none"); + this._splitter.setAttribute("resizeafter", "sibling"); + + this.frame = createDevToolsFrame( + ownerDocument, + "devtools-toolbox-bottom-iframe" + ); + this.frame.style.height = + Math.min( + Services.prefs.getIntPref(this.heightPref), + this._browserContainer.clientHeight - MIN_PAGE_SIZE + ) + "px"; + + this._browserContainer.appendChild(this._splitter); + this._browserContainer.appendChild(this.frame); + + focusTab(this.hostTab); + return this.frame; + }, + + /** + * Raise the host. + */ + raise() { + focusTab(this.hostTab); + }, + + /** + * Set the toolbox title. + * Nothing to do for this host type. + */ + setTitle() {}, + + /** + * Destroy the bottom dock. + */ + destroy() { + if (!this._destroyed) { + this._destroyed = true; + + const height = parseInt(this.frame.style.height, 10); + if (!isNaN(height)) { + Services.prefs.setIntPref(this.heightPref, height); + } + + this._browserContainer.removeChild(this._splitter); + this._browserContainer.removeChild(this.frame); + this.frame = null; + this._browserContainer = null; + this._splitter = null; + } + + return Promise.resolve(null); + }, +}; + +/** + * Base Host object for the in-browser sidebar + */ +class SidebarHost { + constructor(hostTab, type) { + this.hostTab = hostTab; + this.type = type; + this.widthPref = "devtools.toolbox.sidebar.width"; + + EventEmitter.decorate(this); + } + + /** + * Create a box in the sidebar of the host tab. + */ + async create() { + await gDevToolsBrowser.loadBrowserStyleSheet(this.hostTab.ownerGlobal); + const gBrowser = this.hostTab.ownerDocument.defaultView.gBrowser; + const ownerDocument = gBrowser.ownerDocument; + this._browserContainer = gBrowser.getBrowserContainer( + this.hostTab.linkedBrowser + ); + this._browserPanel = gBrowser.getPanel(this.hostTab.linkedBrowser); + + this._splitter = ownerDocument.createXULElement("splitter"); + this._splitter.setAttribute("class", "devtools-side-splitter"); + + this.frame = createDevToolsFrame( + ownerDocument, + "devtools-toolbox-side-iframe" + ); + this.frame.style.width = + Math.min( + Services.prefs.getIntPref(this.widthPref), + this._browserPanel.clientWidth - MIN_PAGE_SIZE + ) + "px"; + + // We should consider the direction when changing the dock position. + const topWindow = this.hostTab.ownerDocument.defaultView.top; + const topDoc = topWindow.document.documentElement; + const isLTR = topWindow.getComputedStyle(topDoc).direction === "ltr"; + + this._splitter.setAttribute("resizebefore", "none"); + this._splitter.setAttribute("resizeafter", "none"); + + if ((isLTR && this.type == "right") || (!isLTR && this.type == "left")) { + this._splitter.setAttribute("resizeafter", "sibling"); + this._browserPanel.appendChild(this._splitter); + this._browserPanel.appendChild(this.frame); + } else { + this._splitter.setAttribute("resizebefore", "sibling"); + this._browserPanel.insertBefore(this.frame, this._browserContainer); + this._browserPanel.insertBefore(this._splitter, this._browserContainer); + } + + focusTab(this.hostTab); + return this.frame; + } + + /** + * Raise the host. + */ + raise() { + focusTab(this.hostTab); + } + + /** + * Set the toolbox title. + * Nothing to do for this host type. + */ + setTitle() {} + + /** + * Destroy the sidebar. + */ + destroy() { + if (!this._destroyed) { + this._destroyed = true; + + const width = parseInt(this.frame.style.width, 10); + if (!isNaN(width)) { + Services.prefs.setIntPref(this.widthPref, width); + } + + this._browserPanel.removeChild(this._splitter); + this._browserPanel.removeChild(this.frame); + } + + return Promise.resolve(null); + } +} + +/** + * Host object for the in-browser left sidebar + */ +class LeftHost extends SidebarHost { + constructor(hostTab) { + super(hostTab, "left"); + } +} + +/** + * Host object for the in-browser right sidebar + */ +class RightHost extends SidebarHost { + constructor(hostTab) { + super(hostTab, "right"); + } +} + +/** + * Host object for the toolbox in a separate window + */ +function WindowHost(hostTab, options) { + this._boundUnload = this._boundUnload.bind(this); + this.hostTab = hostTab; + this.options = options; + EventEmitter.decorate(this); +} + +WindowHost.prototype = { + type: "window", + + WINDOW_URL: "chrome://devtools/content/framework/toolbox-window.xhtml", + + /** + * Create a new xul window to contain the toolbox. + */ + create() { + return new Promise(resolve => { + let flags = "chrome,centerscreen,resizable,dialog=no"; + + // If we are debugging a tab which is in a Private window, we must also + // set the private flag on the DevTools host window. Otherwise switching + // hosts between docked and window modes can fail due to incompatible + // docshell origin attributes. See 1581093. + const owner = this.hostTab?.ownerGlobal; + if (owner && lazy.PrivateBrowsingUtils.isWindowPrivate(owner)) { + flags += ",private"; + } + + // If the current window is a non-fission window, force the non-fission + // flag. Otherwise switching to window host from a non-fission window in + // a fission Firefox (!) will attempt to swapFrameLoaders between fission + // and non-fission frames. See Bug 1650963. + if (this.hostTab && !this.hostTab.ownerGlobal.gFissionBrowser) { + flags += ",non-fission"; + } + + // When debugging local Web Extension, the toolbox is opened in an + // always foremost top level window in order to be kept visible + // when interacting with the Firefox Window. + if (this.options?.alwaysOnTop) { + flags += ",alwaysontop"; + } + + const win = Services.ww.openWindow( + null, + this.WINDOW_URL, + "_blank", + flags, + null + ); + + const frameLoad = () => { + win.removeEventListener("load", frameLoad, true); + win.focus(); + + this.frame = createDevToolsFrame( + win.document, + "devtools-toolbox-window-iframe" + ); + win.document + .getElementById("devtools-toolbox-window") + .appendChild(this.frame); + + // The forceOwnRefreshDriver attribute is set to avoid Windows only issues with + // CSS transitions when switching from docked to window hosts. + // Added in Bug 832920, should be reviewed in Bug 1542468. + this.frame.setAttribute("forceOwnRefreshDriver", ""); + resolve(this.frame); + }; + + win.addEventListener("load", frameLoad, true); + win.addEventListener("unload", this._boundUnload); + + this._window = win; + }); + }, + + /** + * Catch the user closing the window. + */ + _boundUnload(event) { + if (event.target.location != this.WINDOW_URL) { + return; + } + this._window.removeEventListener("unload", this._boundUnload); + + this.emit("window-closed"); + }, + + /** + * Raise the host. + */ + raise() { + this._window.focus(); + }, + + /** + * Set the toolbox title. + */ + setTitle(title) { + this._window.document.title = title; + }, + + /** + * Destroy the window. + */ + destroy() { + if (!this._destroyed) { + this._destroyed = true; + + this._window.removeEventListener("unload", this._boundUnload); + this._window.close(); + } + + return Promise.resolve(null); + }, +}; + +/** + * Host object for the Browser Toolbox + */ +function BrowserToolboxHost(hostTab, options) { + this.doc = options.doc; + EventEmitter.decorate(this); +} + +BrowserToolboxHost.prototype = { + type: "browsertoolbox", + + async create() { + this.frame = createDevToolsFrame( + this.doc, + "devtools-toolbox-browsertoolbox-iframe" + ); + + this.doc.body.appendChild(this.frame); + + return this.frame; + }, + + /** + * Raise the host. + */ + raise() { + this.doc.defaultView.focus(); + }, + + /** + * Set the toolbox title. + */ + setTitle(title) { + this.doc.title = title; + }, + + // Do nothing. The BrowserToolbox is destroyed by quitting the application. + destroy() { + return Promise.resolve(null); + }, +}; + +/** + * Host object for the toolbox as a page. + * This is typically used by `about:debugging`, when opening toolbox in a new tab, + * via `about:devtools-toolbox` URLs. + * The `iframe` ends up being the tab's browser element. + */ +function PageHost(hostTab, options) { + this.frame = options.customIframe; +} + +PageHost.prototype = { + type: "page", + + create() { + return Promise.resolve(this.frame); + }, + + // Do nothing. + raise() {}, + + // Do nothing. + setTitle(title) {}, + + // Do nothing. + destroy() { + return Promise.resolve(null); + }, +}; + +/** + * Switch to the given tab in a browser and focus the browser window + */ +function focusTab(tab) { + const browserWindow = tab.ownerDocument.defaultView; + browserWindow.focus(); + browserWindow.gBrowser.selectedTab = tab; +} + +/** + * Create an iframe that can be used to load DevTools via about:devtools-toolbox. + */ +function createDevToolsFrame(doc, className) { + const frame = doc.createXULElement("browser"); + frame.setAttribute("type", "content"); + frame.style.flex = "1 auto"; // Required to be able to shrink when the window shrinks + frame.className = className; + + const inXULDocument = doc.documentElement.namespaceURI === XUL_NS; + if (inXULDocument) { + // When the toolbox frame is loaded in a XUL document, tooltips rely on a + // special XUL <tooltip id="aHTMLTooltip"> element. + // This attribute should not be set when the frame is loaded in a HTML + // document (for instance: Browser Toolbox). + frame.tooltip = "aHTMLTooltip"; + } + return frame; +} + +exports.Hosts = { + bottom: BottomHost, + left: LeftHost, + right: RightHost, + window: WindowHost, + browsertoolbox: BrowserToolboxHost, + page: PageHost, +}; diff --git a/devtools/client/framework/toolbox-init.js b/devtools/client/framework/toolbox-init.js new file mode 100644 index 0000000000..de2bce080a --- /dev/null +++ b/devtools/client/framework/toolbox-init.js @@ -0,0 +1,135 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env browser */ + +"use strict"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); + +// URL constructor doesn't support about: scheme +const href = window.location.href.replace("about:", "http://"); +const url = new window.URL(href); + +// `host` is the frame element loading the toolbox. +let host = window.browsingContext.embedderElement; + +// If there's no containerElement (which happens when loading about:devtools-toolbox as +// a top level document), use the current window. +if (!host) { + host = { + contentWindow: window, + contentDocument: document, + // toolbox-host-manager.js wants to set attributes on the frame that contains it, + // but that is fine to skip and doesn't make sense when using the current window. + setAttribute() {}, + ownerDocument: document, + // toolbox-host-manager.js wants to listen for unload events from outside the frame, + // but this is fine to skip since the toolbox code listens inside the frame as well, + // and there is no outer document in this case. + addEventListener() {}, + }; +} + +const onLoad = new Promise(r => { + host.contentWindow.addEventListener("DOMContentLoaded", r, { once: true }); +}); + +async function showErrorPage(doc, errorMessage) { + const win = doc.defaultView; + const { BrowserLoader } = ChromeUtils.import( + "resource://devtools/shared/loader/browser-loader.js" + ); + const browserRequire = BrowserLoader({ + window: win, + useOnlyShared: true, + }).require; + + const React = browserRequire("devtools/client/shared/vendor/react"); + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const DebugTargetErrorPage = React.createFactory( + require("resource://devtools/client/framework/components/DebugTargetErrorPage.js") + ); + const { LocalizationHelper } = browserRequire("devtools/shared/l10n"); + const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" + ); + + // mount the React component into our XUL container once the DOM is ready + await onLoad; + + // Update the tab title. + document.title = L10N.getStr("toolbox.debugTargetInfo.tabTitleError"); + + const mountEl = doc.querySelector("#toolbox-error-mount"); + const element = DebugTargetErrorPage({ + errorMessage, + L10N, + }); + ReactDOM.render(element, mountEl); + + // make sure we unmount the component when the page is destroyed + win.addEventListener( + "unload", + () => { + ReactDOM.unmountComponentAtNode(mountEl); + }, + { once: true } + ); +} + +async function initToolbox(url, host) { + const { + gDevTools, + } = require("resource://devtools/client/framework/devtools.js"); + + const { + commandsFromURL, + } = require("resource://devtools/client/framework/commands-from-url.js"); + const { + Toolbox, + } = require("resource://devtools/client/framework/toolbox.js"); + + // Specify the default tool to open + const tool = url.searchParams.get("tool"); + + try { + const commands = await commandsFromURL(url); + const toolbox = gDevTools.getToolboxForCommands(commands); + if (toolbox && toolbox.isDestroying()) { + // If a toolbox already exists for the commands, wait for current + // toolbox destroy to be finished. + await toolbox.destroy(); + } + + // Display an error page if we are connected to a remote target and we lose it + commands.descriptorFront.once("descriptor-destroyed", function () { + // Prevent trying to display the error page if the toolbox tab is being destroyed + if (host.contentDocument) { + const error = new Error("Debug target was disconnected"); + showErrorPage(host.contentDocument, `${error}`); + } + }); + + const options = { customIframe: host }; + await gDevTools.showToolbox(commands, { + toolId: tool, + hostType: Toolbox.HostType.PAGE, + hostOptions: options, + }); + } catch (error) { + // When an error occurs, show error page with message. + console.error("Exception while loading the toolbox", error); + showErrorPage(host.contentDocument, `${error}`); + } +} + +// Only use this method to attach the toolbox if some query parameters are given +if (url.search.length > 1) { + initToolbox(url, host); +} +// TODO: handle no params in about:devtool-toolbox +// https://bugzilla.mozilla.org/show_bug.cgi?id=1526996 diff --git a/devtools/client/framework/toolbox-options.html b/devtools/client/framework/toolbox-options.html new file mode 100644 index 0000000000..2ff33a581f --- /dev/null +++ b/devtools/client/framework/toolbox-options.html @@ -0,0 +1,275 @@ +<!-- 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 dir=""> + <head> + <title>Toolbox option</title> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <link + rel="stylesheet" + href="chrome://devtools/content/framework/options-panel.css" + /> + <script src="chrome://devtools/content/shared/theme-switching.js"></script> + <link rel="localization" href="devtools/client/toolbox-options.ftl" /> + </head> + <body role="application" class="theme-body"> + <form id="options-panel"> + <div id="tools-box" class="options-vertical-pane"> + <fieldset id="default-tools-box" class="options-groupbox"> + <legend data-l10n-id="options-select-default-tools-label"></legend> + <span + id="tools-not-supported-label" + class="options-citation-label theme-comment" + data-l10n-id="options-tool-not-supported-label" + ></span> + </fieldset> + + <fieldset id="additional-tools-box" class="options-groupbox"> + <legend data-l10n-id="options-select-additional-tools-label"></legend> + </fieldset> + + <fieldset id="enabled-toolbox-buttons-box" class="options-groupbox"> + <legend + data-l10n-id="options-select-enabled-toolbox-buttons-label" + ></legend> + </fieldset> + </div> + + <div class="options-vertical-pane"> + <fieldset + id="devtools-theme-box" + class="options-groupbox horizontal-options-groupbox radiogroup" + data-pref="devtools.theme" + > + <legend data-l10n-id="options-select-dev-tools-theme-label"></legend> + </fieldset> + + <fieldset id="inspector-options" class="options-groupbox"> + <legend data-l10n-id="options-context-inspector"></legend> + <label data-l10n-id="options-show-user-agent-styles-tooltip"> + <input + type="checkbox" + data-pref="devtools.inspector.showUserAgentStyles" + /> + <span data-l10n-id="options-show-user-agent-styles-label"></span> + </label> + <label data-l10n-id="options-collapse-attrs-tooltip"> + <input + type="checkbox" + data-pref="devtools.markup.collapseAttributes" + /> + <span data-l10n-id="options-collapse-attrs-label"></span> + </label> + <label data-l10n-id="options-inspector-draggable-properties-tooltip"> + <input + type="checkbox" + data-pref="devtools.inspector.draggable_properties" + /> + <span + data-l10n-id="options-inspector-draggable-properties-label" + ></span> + </label> + <label + data-l10n-id="options-inspector-simplified-highlighters-tooltip" + > + <input + type="checkbox" + data-pref="devtools.inspector.simple-highlighters-reduced-motion" + /> + <span + data-l10n-id="options-inspector-simplified-highlighters-label" + ></span> + </label> + <label + data-l10n-id="options-inspector-rules-focus-next-on-enter-tooltip" + > + <input + type="checkbox" + data-pref="devtools.inspector.rule-view.focusNextOnEnter" + /> + <span + data-l10n-id="options-inspector-rules-focus-next-on-enter-label" + ></span> + </label> + <label> + <span data-l10n-id="options-default-color-unit-label"></span> + <select + id="defaultColorUnitMenuList" + data-pref="devtools.defaultColorUnit" + > + <option + value="authored" + data-l10n-id="options-default-color-unit-authored" + ></option> + <option + value="hex" + data-l10n-id="options-default-color-unit-hex" + ></option> + <option + value="hsl" + data-l10n-id="options-default-color-unit-hsl" + ></option> + <option + value="rgb" + data-l10n-id="options-default-color-unit-rgb" + ></option> + <option + value="hwb" + data-l10n-id="options-default-color-unit-hwb" + ></option> + <option + value="name" + data-l10n-id="options-default-color-unit-name" + ></option> + </select> + </label> + </fieldset> + + <fieldset id="styleeditor-options" class="options-groupbox"> + <legend data-l10n-id="options-styleeditor-label"></legend> + <label data-l10n-id="options-stylesheet-autocompletion-tooltip"> + <input + type="checkbox" + data-pref="devtools.styleeditor.autocompletion-enabled" + /> + <span data-l10n-id="options-stylesheet-autocompletion-label"></span> + </label> + </fieldset> + + <fieldset id="screenshot-options" class="options-groupbox"> + <legend data-l10n-id="options-screenshot-label"></legend> + <label data-l10n-id="options-screenshot-clipboard-tooltip2"> + <input + type="checkbox" + id="devtools-screenshot-clipboard" + data-pref="devtools.screenshot.clipboard.enabled" + /> + <span data-l10n-id="options-screenshot-clipboard-only-label"></span> + </label> + <label data-l10n-id="options-screenshot-audio-tooltip"> + <input + type="checkbox" + id="devtools-screenshot-audio" + data-pref="devtools.screenshot.audio.enabled" + /> + <span data-l10n-id="options-screenshot-audio-label"></span> + </label> + </fieldset> + </div> + + <div class="options-vertical-pane"> + <fieldset id="sourceeditor-options" class="options-groupbox"> + <legend data-l10n-id="options-sourceeditor-label"></legend> + <label data-l10n-id="options-sourceeditor-detectindentation-tooltip"> + <input + type="checkbox" + id="devtools-sourceeditor-detectindentation" + data-pref="devtools.editor.detectindentation" + /> + <span + data-l10n-id="options-sourceeditor-detectindentation-label" + ></span> + </label> + <label data-l10n-id="options-sourceeditor-autoclosebrackets-tooltip"> + <input + type="checkbox" + id="devtools-sourceeditor-autoclosebrackets" + data-pref="devtools.editor.autoclosebrackets" + /> + <span + data-l10n-id="options-sourceeditor-autoclosebrackets-label" + ></span> + </label> + <label data-l10n-id="options-sourceeditor-expandtab-tooltip"> + <input + type="checkbox" + id="devtools-sourceeditor-expandtab" + data-pref="devtools.editor.expandtab" + /> + <span data-l10n-id="options-sourceeditor-expandtab-label"></span> + </label> + <label> + <span data-l10n-id="options-sourceeditor-tabsize-label"></span> + <select + id="devtools-sourceeditor-tabsize-select" + data-pref="devtools.editor.tabsize" + > + <option label="2">2</option> + <option label="4">4</option> + <option label="8">8</option> + </select> + </label> + <label> + <span data-l10n-id="options-sourceeditor-keybinding-label"></span> + <select + id="devtools-sourceeditor-keybinding-select" + data-pref="devtools.editor.keymap" + > + <option + value="default" + data-l10n-id="options-sourceeditor-keybinding-default-label" + ></option> + <option value="vim">Vim</option> + <option value="emacs">Emacs</option> + <option value="sublime">Sublime Text</option> + </select> + </label> + </fieldset> + + <fieldset id="context-options" class="options-groupbox"> + <legend data-l10n-id="options-context-advanced-settings"></legend> + <label data-l10n-id="options-source-maps-tooltip"> + <input + type="checkbox" + data-pref="devtools.source-map.client-service.enabled" + /> + <span data-l10n-id="options-source-maps-label"></span> + </label> + <label data-l10n-id="options-disable-http-cache-tooltip"> + <input + type="checkbox" + id="devtools-disable-cache" + data-pref="devtools.cache.disabled" + /> + <span data-l10n-id="options-disable-http-cache-label"></span> + </label> + <label data-l10n-id="options-disable-javascript-tooltip"> + <input type="checkbox" id="devtools-disable-javascript" /> + <span data-l10n-id="options-disable-javascript-label"></span> + </label> + <label data-l10n-id="options-enable-service-workers-http-tooltip"> + <input + type="checkbox" + id="devtools-enable-serviceWorkersTesting" + data-pref="devtools.serviceWorkers.testing.enabled" + /> + <span + data-l10n-id="options-enable-service-workers-http-label" + ></span> + </label> + <label data-l10n-id="options-enable-chrome-tooltip"> + <input type="checkbox" data-pref="devtools.chrome.enabled" /> + <span data-l10n-id="options-enable-chrome-label"></span> + </label> + <label data-l10n-id="options-enable-remote-tooltip2"> + <input + type="checkbox" + data-pref="devtools.debugger.remote-enabled" + /> + <span data-l10n-id="options-enable-remote-label"></span> + </label> + <label data-l10n-id="options-enable-f12-tooltip"> + <input type="checkbox" data-pref="devtools.f12_enabled" /> + <span data-l10n-id="options-enable-f12-label"></span> + </label> + <span + class="options-citation-label theme-comment" + id="triggers-page-refresh-label" + data-l10n-id="options-context-triggers-page-refresh" + ></span> + </fieldset> + </div> + </form> + </body> +</html> diff --git a/devtools/client/framework/toolbox-options.js b/devtools/client/framework/toolbox-options.js new file mode 100644 index 0000000000..57fa1202b7 --- /dev/null +++ b/devtools/client/framework/toolbox-options.js @@ -0,0 +1,613 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + gDevTools, +} = require("resource://devtools/client/framework/devtools.js"); + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +loader.lazyRequireGetter( + this, + "openDocLink", + "resource://devtools/client/shared/link.js", + true +); + +exports.OptionsPanel = OptionsPanel; + +function GetPref(name) { + const type = Services.prefs.getPrefType(name); + switch (type) { + case Services.prefs.PREF_STRING: + return Services.prefs.getCharPref(name); + case Services.prefs.PREF_INT: + return Services.prefs.getIntPref(name); + case Services.prefs.PREF_BOOL: + return Services.prefs.getBoolPref(name); + default: + throw new Error("Unknown type"); + } +} + +function SetPref(name, value) { + const type = Services.prefs.getPrefType(name); + switch (type) { + case Services.prefs.PREF_STRING: + return Services.prefs.setCharPref(name, value); + case Services.prefs.PREF_INT: + return Services.prefs.setIntPref(name, value); + case Services.prefs.PREF_BOOL: + return Services.prefs.setBoolPref(name, value); + default: + throw new Error("Unknown type"); + } +} + +function InfallibleGetBoolPref(key) { + try { + return Services.prefs.getBoolPref(key); + } catch (ex) { + return true; + } +} + +/** + * Represents the Options Panel in the Toolbox. + */ +function OptionsPanel(iframeWindow, toolbox, commands) { + this.panelDoc = iframeWindow.document; + this.panelWin = iframeWindow; + + this.toolbox = toolbox; + this.commands = commands; + this.telemetry = toolbox.telemetry; + + this.setupToolsList = this.setupToolsList.bind(this); + this._prefChanged = this._prefChanged.bind(this); + this._themeRegistered = this._themeRegistered.bind(this); + this._themeUnregistered = this._themeUnregistered.bind(this); + this._disableJSClicked = this._disableJSClicked.bind(this); + + this.disableJSNode = this.panelDoc.getElementById( + "devtools-disable-javascript" + ); + + this._addListeners(); + + const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + EventEmitter.decorate(this); +} + +OptionsPanel.prototype = { + get target() { + return this.toolbox.target; + }, + + async open() { + this.setupToolsList(); + this.setupToolbarButtonsList(); + this.setupThemeList(); + this.setupAdditionalOptions(); + await this.populatePreferences(); + return this; + }, + + _addListeners() { + Services.prefs.addObserver("devtools.cache.disabled", this._prefChanged); + Services.prefs.addObserver("devtools.theme", this._prefChanged); + Services.prefs.addObserver( + "devtools.source-map.client-service.enabled", + this._prefChanged + ); + gDevTools.on("theme-registered", this._themeRegistered); + gDevTools.on("theme-unregistered", this._themeUnregistered); + + // Refresh the tools list when a new tool or webextension has been + // registered to the toolbox. + this.toolbox.on("tool-registered", this.setupToolsList); + this.toolbox.on("webextension-registered", this.setupToolsList); + // Refresh the tools list when a new tool or webextension has been + // unregistered from the toolbox. + this.toolbox.on("tool-unregistered", this.setupToolsList); + this.toolbox.on("webextension-unregistered", this.setupToolsList); + }, + + _removeListeners() { + Services.prefs.removeObserver("devtools.cache.disabled", this._prefChanged); + Services.prefs.removeObserver("devtools.theme", this._prefChanged); + Services.prefs.removeObserver( + "devtools.source-map.client-service.enabled", + this._prefChanged + ); + + this.toolbox.off("tool-registered", this.setupToolsList); + this.toolbox.off("tool-unregistered", this.setupToolsList); + this.toolbox.off("webextension-registered", this.setupToolsList); + this.toolbox.off("webextension-unregistered", this.setupToolsList); + + gDevTools.off("theme-registered", this._themeRegistered); + gDevTools.off("theme-unregistered", this._themeUnregistered); + }, + + _prefChanged(subject, topic, prefName) { + if (prefName === "devtools.cache.disabled") { + const cacheDisabled = GetPref(prefName); + const cbx = this.panelDoc.getElementById("devtools-disable-cache"); + cbx.checked = cacheDisabled; + } else if (prefName === "devtools.theme") { + this.updateCurrentTheme(); + } else if (prefName === "devtools.source-map.client-service.enabled") { + this.updateSourceMapPref(); + } + }, + + _themeRegistered(themeId) { + this.setupThemeList(); + }, + + _themeUnregistered(theme) { + const themeBox = this.panelDoc.getElementById("devtools-theme-box"); + const themeInput = themeBox.querySelector(`[value=${theme.id}]`); + + if (themeInput) { + themeInput.parentNode.remove(); + } + }, + + async setupToolbarButtonsList() { + // Ensure the toolbox is open, and the buttons are all set up. + await this.toolbox.isOpen; + + const enabledToolbarButtonsBox = this.panelDoc.getElementById( + "enabled-toolbox-buttons-box" + ); + + const toolbarButtons = this.toolbox.toolbarButtons; + + if (!toolbarButtons) { + console.warn("The command buttons weren't initiated yet."); + return; + } + + const onCheckboxClick = checkbox => { + const commandButton = toolbarButtons.filter( + toggleableButton => toggleableButton.id === checkbox.id + )[0]; + + Services.prefs.setBoolPref( + commandButton.visibilityswitch, + checkbox.checked + ); + this.toolbox.updateToolboxButtonsVisibility(); + }; + + const createCommandCheckbox = button => { + const checkboxLabel = this.panelDoc.createElement("label"); + const checkboxSpanLabel = this.panelDoc.createElement("span"); + checkboxSpanLabel.textContent = button.description; + const checkboxInput = this.panelDoc.createElement("input"); + checkboxInput.setAttribute("type", "checkbox"); + checkboxInput.setAttribute("id", button.id); + + if (Services.prefs.getBoolPref(button.visibilityswitch, true)) { + checkboxInput.setAttribute("checked", true); + } + checkboxInput.addEventListener( + "change", + onCheckboxClick.bind(this, checkboxInput) + ); + + checkboxLabel.appendChild(checkboxInput); + checkboxLabel.appendChild(checkboxSpanLabel); + + return checkboxLabel; + }; + + for (const button of toolbarButtons) { + if (!button.isToolSupported(this.toolbox)) { + continue; + } + + enabledToolbarButtonsBox.appendChild(createCommandCheckbox(button)); + } + }, + + setupToolsList() { + const defaultToolsBox = this.panelDoc.getElementById("default-tools-box"); + const additionalToolsBox = this.panelDoc.getElementById( + "additional-tools-box" + ); + const toolsNotSupportedLabel = this.panelDoc.getElementById( + "tools-not-supported-label" + ); + let atleastOneToolNotSupported = false; + + // Signal tool registering/unregistering globally (for the tools registered + // globally) and per toolbox (for the tools registered to a single toolbox). + // This event handler expect this to be binded to the related checkbox element. + const onCheckboxClick = function (telemetry, tool) { + // Set the kill switch pref boolean to true + Services.prefs.setBoolPref(tool.visibilityswitch, this.checked); + + if (!tool.isWebExtension) { + gDevTools.emit( + this.checked ? "tool-registered" : "tool-unregistered", + tool.id + ); + // Record which tools were registered and unregistered. + telemetry.keyedScalarSet( + "devtools.tool.registered", + tool.id, + this.checked + ); + } + }; + + const createToolCheckbox = tool => { + const checkboxLabel = this.panelDoc.createElement("label"); + const checkboxInput = this.panelDoc.createElement("input"); + checkboxInput.setAttribute("type", "checkbox"); + checkboxInput.setAttribute("id", tool.id); + checkboxInput.setAttribute("title", tool.tooltip || ""); + + const checkboxSpanLabel = this.panelDoc.createElement("span"); + if (tool.isToolSupported(this.toolbox)) { + checkboxSpanLabel.textContent = tool.label; + } else { + atleastOneToolNotSupported = true; + checkboxSpanLabel.textContent = L10N.getFormatStr( + "options.toolNotSupportedMarker", + tool.label + ); + checkboxInput.setAttribute("data-unsupported", "true"); + checkboxInput.setAttribute("disabled", "true"); + } + + if (InfallibleGetBoolPref(tool.visibilityswitch)) { + checkboxInput.setAttribute("checked", "true"); + } + + checkboxInput.addEventListener( + "change", + onCheckboxClick.bind(checkboxInput, this.telemetry, tool) + ); + + checkboxLabel.appendChild(checkboxInput); + checkboxLabel.appendChild(checkboxSpanLabel); + + // We shouldn't have deprecated tools anymore, but we might have one in the future, + // when migrating the storage inspector to the application panel (Bug 1681059). + // Let's keep this code for now so we keep the l10n property around and avoid + // unnecessary translation work if we need it again in the future. + if (tool.deprecated) { + const deprecationURL = this.panelDoc.createElement("a"); + deprecationURL.title = deprecationURL.href = tool.deprecationURL; + deprecationURL.textContent = L10N.getStr("options.deprecationNotice"); + // Cannot use a real link when we are in the Browser Toolbox. + deprecationURL.addEventListener("click", e => { + e.preventDefault(); + openDocLink(tool.deprecationURL, { relatedToCurrent: true }); + }); + + const checkboxSpanDeprecated = this.panelDoc.createElement("span"); + checkboxSpanDeprecated.className = "deprecation-notice"; + checkboxLabel.appendChild(checkboxSpanDeprecated); + checkboxSpanDeprecated.appendChild(deprecationURL); + } + + return checkboxLabel; + }; + + // Clean up any existent default tools content. + for (const label of defaultToolsBox.querySelectorAll("label")) { + label.remove(); + } + + // Populating the default tools lists + const toggleableTools = gDevTools.getDefaultTools().filter(tool => { + return tool.visibilityswitch && !tool.hiddenInOptions; + }); + + const fragment = this.panelDoc.createDocumentFragment(); + for (const tool of toggleableTools) { + fragment.appendChild(createToolCheckbox(tool)); + } + + const toolsNotSupportedLabelNode = this.panelDoc.getElementById( + "tools-not-supported-label" + ); + defaultToolsBox.insertBefore(fragment, toolsNotSupportedLabelNode); + + // Clean up any existent additional tools content. + for (const label of additionalToolsBox.querySelectorAll("label")) { + label.remove(); + } + + // Populating the additional tools list. + let atleastOneAddon = false; + for (const tool of gDevTools.getAdditionalTools()) { + atleastOneAddon = true; + additionalToolsBox.appendChild(createToolCheckbox(tool)); + } + + // Populating the additional tools that came from the installed WebExtension add-ons. + for (const { uuid, name, pref } of this.toolbox.listWebExtensions()) { + atleastOneAddon = true; + + additionalToolsBox.appendChild( + createToolCheckbox({ + isWebExtension: true, + + // Use the preference as the unified webextensions tool id. + id: `webext-${uuid}`, + tooltip: name, + label: name, + // Disable the devtools extension using the given pref name: + // the toolbox options for the WebExtensions are not related to a single + // tool (e.g. a devtools panel created from the extension devtools_page) + // but to the entire devtools part of a webextension which is enabled + // by the Addon Manager (but it may be disabled by its related + // devtools about:config preference), and so the following + visibilityswitch: pref, + + // Only local tabs are currently supported as targets. + isToolSupported: toolbox => + toolbox.commands.descriptorFront.isLocalTab, + }) + ); + } + + if (!atleastOneAddon) { + additionalToolsBox.style.display = "none"; + } else { + additionalToolsBox.style.display = ""; + } + + if (!atleastOneToolNotSupported) { + toolsNotSupportedLabel.style.display = "none"; + } else { + toolsNotSupportedLabel.style.display = ""; + } + + this.panelWin.focus(); + }, + + setupThemeList() { + const themeBox = this.panelDoc.getElementById("devtools-theme-box"); + const themeLabels = themeBox.querySelectorAll("label"); + for (const label of themeLabels) { + label.remove(); + } + + const createThemeOption = theme => { + const inputLabel = this.panelDoc.createElement("label"); + const inputRadio = this.panelDoc.createElement("input"); + inputRadio.setAttribute("type", "radio"); + inputRadio.setAttribute("value", theme.id); + inputRadio.setAttribute("name", "devtools-theme-item"); + inputRadio.addEventListener("change", function (e) { + SetPref(themeBox.getAttribute("data-pref"), e.target.value); + }); + + const inputSpanLabel = this.panelDoc.createElement("span"); + inputSpanLabel.textContent = theme.label; + inputLabel.appendChild(inputRadio); + inputLabel.appendChild(inputSpanLabel); + + return inputLabel; + }; + + // Populating the default theme list + themeBox.appendChild( + createThemeOption({ + id: "auto", + label: L10N.getStr("options.autoTheme.label"), + }) + ); + + const themes = gDevTools.getThemeDefinitionArray(); + for (const theme of themes) { + themeBox.appendChild(createThemeOption(theme)); + } + + this.updateCurrentTheme(); + }, + + /** + * Add extra checkbox options bound to a boolean preference. + */ + setupAdditionalOptions() { + const prefDefinitions = [ + { + pref: "devtools.custom-formatters.enabled", + l10nLabelId: "options-enable-custom-formatters-label", + l10nTooltipId: "options-enable-custom-formatters-tooltip", + id: "devtools-custom-formatters", + parentId: "context-options", + }, + ]; + + const createPreferenceOption = ({ + pref, + label, + l10nLabelId, + l10nTooltipId, + id, + onChange, + }) => { + const inputLabel = this.panelDoc.createElement("label"); + if (l10nTooltipId) { + this.panelDoc.l10n.setAttributes(inputLabel, l10nTooltipId); + } + const checkbox = this.panelDoc.createElement("input"); + checkbox.setAttribute("type", "checkbox"); + if (GetPref(pref)) { + checkbox.setAttribute("checked", "checked"); + } + checkbox.setAttribute("id", id); + checkbox.addEventListener("change", e => { + SetPref(pref, e.target.checked); + if (onChange) { + onChange(e.target.checked); + } + }); + + const inputSpanLabel = this.panelDoc.createElement("span"); + if (l10nLabelId) { + this.panelDoc.l10n.setAttributes(inputSpanLabel, l10nLabelId); + } else if (label) { + inputSpanLabel.textContent = label; + } + inputLabel.appendChild(checkbox); + inputLabel.appendChild(inputSpanLabel); + + return inputLabel; + }; + + for (const prefDefinition of prefDefinitions) { + const parent = this.panelDoc.getElementById(prefDefinition.parentId); + // We want to insert the new definition after the last existing + // definition, but before any other element. + // For example in the "Advanced Settings" column there's indeed a <span> + // text at the end, and we want that it stays at the end. + // The reference element can be `null` if there's no label or if there's + // no element after the last label. But that's OK and it will do what we + // want. + const referenceElement = parent.querySelector("label:last-of-type + *"); + parent.insertBefore( + createPreferenceOption(prefDefinition), + referenceElement + ); + } + }, + + async populatePreferences() { + const prefCheckboxes = this.panelDoc.querySelectorAll( + "input[type=checkbox][data-pref]" + ); + for (const prefCheckbox of prefCheckboxes) { + if (GetPref(prefCheckbox.getAttribute("data-pref"))) { + prefCheckbox.setAttribute("checked", true); + } + prefCheckbox.addEventListener("change", function (e) { + const checkbox = e.target; + SetPref(checkbox.getAttribute("data-pref"), checkbox.checked); + }); + } + // Themes radio inputs are handled in setupThemeList + const prefRadiogroups = this.panelDoc.querySelectorAll( + ".radiogroup[data-pref]:not(#devtools-theme-box)" + ); + for (const radioGroup of prefRadiogroups) { + const selectedValue = GetPref(radioGroup.getAttribute("data-pref")); + + for (const radioInput of radioGroup.querySelectorAll( + "input[type=radio]" + )) { + if (radioInput.getAttribute("value") == selectedValue) { + radioInput.setAttribute("checked", true); + } + + radioInput.addEventListener("change", function (e) { + SetPref(radioGroup.getAttribute("data-pref"), e.target.value); + }); + } + } + const prefSelects = this.panelDoc.querySelectorAll("select[data-pref]"); + for (const prefSelect of prefSelects) { + const pref = GetPref(prefSelect.getAttribute("data-pref")); + const options = [...prefSelect.options]; + options.some(function (option) { + const value = option.value; + // non strict check to allow int values. + if (value == pref) { + prefSelect.selectedIndex = options.indexOf(option); + return true; + } + return false; + }); + + prefSelect.addEventListener("change", function (e) { + const select = e.target; + SetPref( + select.getAttribute("data-pref"), + select.options[select.selectedIndex].value + ); + }); + } + + if (this.commands.descriptorFront.isTabDescriptor) { + const isJavascriptEnabled = + await this.commands.targetConfigurationCommand.isJavascriptEnabled(); + this.disableJSNode.checked = !isJavascriptEnabled; + this.disableJSNode.addEventListener("click", this._disableJSClicked); + } else { + // Hide the checkbox and label + this.disableJSNode.parentNode.style.display = "none"; + + const triggersPageRefreshLabel = this.panelDoc.getElementById( + "triggers-page-refresh-label" + ); + triggersPageRefreshLabel.style.display = "none"; + } + }, + + updateCurrentTheme() { + const currentTheme = GetPref("devtools.theme"); + const themeBox = this.panelDoc.getElementById("devtools-theme-box"); + const themeRadioInput = themeBox.querySelector(`[value=${currentTheme}]`); + + if (themeRadioInput) { + themeRadioInput.checked = true; + } else { + // If the current theme does not exist anymore, switch to auto theme + const autoThemeInputRadio = themeBox.querySelector("[value=auto]"); + autoThemeInputRadio.checked = true; + } + }, + + updateSourceMapPref() { + const prefName = "devtools.source-map.client-service.enabled"; + const enabled = GetPref(prefName); + const box = this.panelDoc.querySelector(`[data-pref="${prefName}"]`); + box.checked = enabled; + }, + + /** + * Disables JavaScript for the currently loaded tab. We force a page refresh + * here because setting browsingContext.allowJavascript to true fails to block + * JS execution from event listeners added using addEventListener(), AJAX + * calls and timers. The page refresh prevents these things from being added + * in the first place. + * + * @param {Event} event + * The event sent by checking / unchecking the disable JS checkbox. + */ + _disableJSClicked(event) { + const checked = event.target.checked; + + this.commands.targetConfigurationCommand.updateConfiguration({ + javascriptEnabled: !checked, + }); + }, + + destroy() { + if (this.destroyed) { + return; + } + this.destroyed = true; + + this._removeListeners(); + + this.disableJSNode.removeEventListener("click", this._disableJSClicked); + + this.panelWin = this.panelDoc = this.disableJSNode = this.toolbox = null; + }, +}; diff --git a/devtools/client/framework/toolbox-tabs-order-manager.js b/devtools/client/framework/toolbox-tabs-order-manager.js new file mode 100644 index 0000000000..0eec0c935f --- /dev/null +++ b/devtools/client/framework/toolbox-tabs-order-manager.js @@ -0,0 +1,285 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs", + // AddonManager is a singleton, never create two instances of it. + { loadInDevToolsLoader: false } +); +const { + gDevTools, +} = require("resource://devtools/client/framework/devtools.js"); +const TABS_REORDERED_SCALAR = "devtools.toolbox.tabs_reordered"; +const PREFERENCE_NAME = "devtools.toolbox.tabsOrder"; + +/** + * Manage the order of devtools tabs. + */ +class ToolboxTabsOrderManager { + constructor(toolbox, onOrderUpdated, panelDefinitions) { + this.toolbox = toolbox; + this.onOrderUpdated = onOrderUpdated; + this.currentPanelDefinitions = panelDefinitions || []; + + this.onMouseDown = this.onMouseDown.bind(this); + this.onMouseMove = this.onMouseMove.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); + + Services.prefs.addObserver(PREFERENCE_NAME, this.onOrderUpdated); + } + + async destroy() { + Services.prefs.removeObserver(PREFERENCE_NAME, this.onOrderUpdated); + + // Call mouseUp() to clear the state to prepare for in case a dragging was in progress + // when the destroy() was called. + await this.onMouseUp(); + } + + insertBefore(target) { + const xBefore = this.dragTarget.offsetLeft; + this.toolboxTabsElement.insertBefore(this.dragTarget, target); + const xAfter = this.dragTarget.offsetLeft; + this.dragStartX += xAfter - xBefore; + this.isOrderUpdated = true; + } + + isFirstTab(tabElement) { + return !tabElement.previousSibling; + } + + isLastTab(tabElement) { + return ( + !tabElement.nextSibling || + tabElement.nextSibling.id === "tools-chevron-menu-button" + ); + } + + isRTL() { + return this.toolbox.direction === "rtl"; + } + + async saveOrderPreference() { + const tabs = [...this.toolboxTabsElement.querySelectorAll(".devtools-tab")]; + const tabIds = tabs.map(tab => tab.dataset.extensionId || tab.dataset.id); + // Concat the overflowed tabs id since they are not contained in visible tabs. + // The overflowed tabs cannot be reordered so we just append the id from current + // panel definitions on their order. + const overflowedTabIds = this.currentPanelDefinitions + .filter(definition => !tabs.some(tab => tab.dataset.id === definition.id)) + .map(definition => definition.extensionId || definition.id); + const currentTabIds = tabIds.concat(overflowedTabIds); + const dragTargetId = + this.dragTarget.dataset.extensionId || this.dragTarget.dataset.id; + const prefIds = getTabsOrderFromPreference(); + const absoluteIds = toAbsoluteOrder(prefIds, currentTabIds, dragTargetId); + + // Remove panel id which is not in panel definitions and addons list. + const extensions = await AddonManager.getAllAddons(); + const definitions = gDevTools.getToolDefinitionArray(); + const result = absoluteIds.filter( + id => + definitions.find(d => id === (d.extensionId || d.id)) || + extensions.find(e => id === e.id) + ); + + Services.prefs.setCharPref(PREFERENCE_NAME, result.join(",")); + } + + setCurrentPanelDefinitions(currentPanelDefinitions) { + this.currentPanelDefinitions = currentPanelDefinitions; + } + + onMouseDown(e) { + if (!e.target.classList.contains("devtools-tab")) { + return; + } + + this.dragStartX = e.pageX; + this.dragTarget = e.target; + this.previousPageX = e.pageX; + this.toolboxContainerElement = + this.dragTarget.closest("#toolbox-container"); + this.toolboxTabsElement = this.dragTarget.closest(".toolbox-tabs"); + this.isOrderUpdated = false; + this.eventTarget = this.dragTarget.ownerGlobal.top; + + this.eventTarget.addEventListener("mousemove", this.onMouseMove); + this.eventTarget.addEventListener("mouseup", this.onMouseUp); + + this.toolboxContainerElement.classList.add("tabs-reordering"); + } + + onMouseMove(e) { + const diffPageX = e.pageX - this.previousPageX; + let dragTargetCenterX = + this.dragTarget.offsetLeft + diffPageX + this.dragTarget.clientWidth / 2; + let isDragTargetPreviousSibling = false; + + const tabElements = + this.toolboxTabsElement.querySelectorAll(".devtools-tab"); + + // Calculate the minimum and maximum X-offset that can be valid for the drag target. + const firstElement = tabElements[0]; + const firstElementCenterX = + firstElement.offsetLeft + firstElement.clientWidth / 2; + const lastElement = tabElements[tabElements.length - 1]; + const lastElementCenterX = + lastElement.offsetLeft + lastElement.clientWidth / 2; + const max = Math.max(firstElementCenterX, lastElementCenterX); + const min = Math.min(firstElementCenterX, lastElementCenterX); + + // Normalize the target center X so to remain between the first and last tab. + dragTargetCenterX = Math.min(max, dragTargetCenterX); + dragTargetCenterX = Math.max(min, dragTargetCenterX); + + for (const tabElement of tabElements) { + if (tabElement === this.dragTarget) { + isDragTargetPreviousSibling = true; + continue; + } + + // Is the dragTarget near the center of the other tab? + const anotherCenterX = tabElement.offsetLeft + tabElement.clientWidth / 2; + const distanceWithDragTarget = Math.abs( + dragTargetCenterX - anotherCenterX + ); + const isReplaceable = distanceWithDragTarget < tabElement.clientWidth / 3; + + if (isReplaceable) { + const replaceableElement = isDragTargetPreviousSibling + ? tabElement.nextSibling + : tabElement; + this.insertBefore(replaceableElement); + break; + } + } + + let distance = e.pageX - this.dragStartX; + + // To accomodate for RTL locales, we cannot rely on the first/last element of the + // NodeList. We cannot have negative distances for the leftmost tab, and we cannot + // have positive distances for the rightmost tab. + const isFirstTab = this.isFirstTab(this.dragTarget); + const isLastTab = this.isLastTab(this.dragTarget); + const isLeftmostTab = this.isRTL() ? isLastTab : isFirstTab; + const isRightmostTab = this.isRTL() ? isFirstTab : isLastTab; + + if ((isLeftmostTab && distance < 0) || (isRightmostTab && distance > 0)) { + // If the drag target is already edge of the tabs and the mouse will make the + // element to move to same direction more, keep the position. + distance = 0; + } + + this.dragTarget.style.left = `${distance}px`; + this.previousPageX = e.pageX; + } + + async onMouseUp() { + if (!this.dragTarget) { + // The case in here has two type: + // 1. Although destroy method was called, it was not during reordering. + // 2. Although mouse event occur, destroy method was called during reordering. + return; + } + + if (this.isOrderUpdated) { + await this.saveOrderPreference(); + + // Log which tabs reordered. The question we want to answer is: + // "How frequently are the tabs re-ordered, also which tabs get re-ordered?" + const toolId = + this.dragTarget.dataset.extensionId || this.dragTarget.dataset.id; + this.toolbox.telemetry.keyedScalarAdd(TABS_REORDERED_SCALAR, toolId, 1); + } + + this.eventTarget.removeEventListener("mousemove", this.onMouseMove); + this.eventTarget.removeEventListener("mouseup", this.onMouseUp); + + this.toolboxContainerElement.classList.remove("tabs-reordering"); + this.dragTarget.style.left = null; + this.dragTarget = null; + this.toolboxContainerElement = null; + this.toolboxTabsElement = null; + this.eventTarget = null; + } +} + +function getTabsOrderFromPreference() { + const pref = Services.prefs.getCharPref(PREFERENCE_NAME, ""); + return pref ? pref.split(",") : []; +} + +function sortPanelDefinitions(definitions) { + const toolIds = getTabsOrderFromPreference(); + + return definitions.sort((a, b) => { + let orderA = toolIds.indexOf(a.extensionId || a.id); + let orderB = toolIds.indexOf(b.extensionId || b.id); + orderA = orderA < 0 ? Number.MAX_VALUE : orderA; + orderB = orderB < 0 ? Number.MAX_VALUE : orderB; + return orderA - orderB; + }); +} + +/* + * This function returns absolute tab ids that were merged the both ids that are in + * preference and tabs. + * Some tabs added with add-ons etc show/hide depending on conditions. + * However, all of tabs that include hidden tab always keep the relationship with + * left side tab, except in case the left tab was target of dragging. If the left + * tab has been moved, it keeps its relationship with the tab next to it. + * + * Case 1: Drag a tab to left + * currentTabIds: [T1, T2, T3, T4, T5] + * prefIds : [T1, T2, T3, E1(hidden), T4, T5] + * drag T4 : [T1, T2, T4, T3, T5] + * result : [T1, T2, T4, T3, E1, T5] + * + * Case 2: Drag a tab to right + * currentTabIds: [T1, T2, T3, T4, T5] + * prefIds : [T1, T2, T3, E1(hidden), T4, T5] + * drag T2 : [T1, T3, T4, T2, T5] + * result : [T1, T3, E1, T4, T2, T5] + * + * Case 3: Hidden tab was left end and drag a tab to left end + * currentTabIds: [T1, T2, T3, T4, T5] + * prefIds : [E1(hidden), T1, T2, T3, T4, T5] + * drag T4 : [T4, T1, T2, T3, T5] + * result : [E1, T4, T1, T2, T3, T5] + * + * Case 4: Hidden tab was right end and drag a tab to right end + * currentTabIds: [T1, T2, T3, T4, T5] + * prefIds : [T1, T2, T3, T4, T5, E1(hidden)] + * drag T1 : [T2, T3, T4, T5, T1] + * result : [T2, T3, T4, T5, E1, T1] + * + * @param Array - prefIds: id array of preference + * @param Array - currentTabIds: id array of appearanced tabs + * @param String - dragTargetId: id of dragged target + * @return Array + */ +function toAbsoluteOrder(prefIds, currentTabIds, dragTargetId) { + currentTabIds = [...currentTabIds]; + let indexAtCurrentTabs = 0; + + for (const prefId of prefIds) { + if (prefId === dragTargetId) { + // do nothing + } else if (currentTabIds.includes(prefId)) { + indexAtCurrentTabs = currentTabIds.indexOf(prefId) + 1; + } else { + currentTabIds.splice(indexAtCurrentTabs, 0, prefId); + indexAtCurrentTabs += 1; + } + } + + return currentTabIds; +} + +module.exports.ToolboxTabsOrderManager = ToolboxTabsOrderManager; +module.exports.sortPanelDefinitions = sortPanelDefinitions; +module.exports.toAbsoluteOrder = toAbsoluteOrder; diff --git a/devtools/client/framework/toolbox-window.js b/devtools/client/framework/toolbox-window.js new file mode 100644 index 0000000000..0a179ad80f --- /dev/null +++ b/devtools/client/framework/toolbox-window.js @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* eslint-disable no-unused-vars */ + +"use strict"; + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +// Make some `topBrowsingContext.topChromeWindow` properties available in +// separate devtools window. +// +// (see bug 1659618) +XPCOMUtils.defineLazyScriptGetter( + this, + "ZoomManager", + "chrome://global/content/viewZoomOverlay.js" +); diff --git a/devtools/client/framework/toolbox-window.xhtml b/devtools/client/framework/toolbox-window.xhtml new file mode 100644 index 0000000000..3943ac0568 --- /dev/null +++ b/devtools/client/framework/toolbox-window.xhtml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> + +<!-- minwidth=50 is sum width of chevron and meatball menu button. --> +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + id="devtools-toolbox-window" + macanimationtype="document" + windowtype="devtools:toolbox" + width="900" + height="320" + persist="screenX screenY width height sizemode" +> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + + <tooltip id="aHTMLTooltip" page="true" /> + <script src="resource://devtools/client/framework/toolbox-window.js" /> +</window> diff --git a/devtools/client/framework/toolbox.js b/devtools/client/framework/toolbox.js new file mode 100644 index 0000000000..2294ff879d --- /dev/null +++ b/devtools/client/framework/toolbox.js @@ -0,0 +1,4787 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const MAX_ORDINAL = 99; +const SPLITCONSOLE_ENABLED_PREF = "devtools.toolbox.splitconsoleEnabled"; +const SPLITCONSOLE_HEIGHT_PREF = "devtools.toolbox.splitconsoleHeight"; +const DEVTOOLS_ALWAYS_ON_TOP = "devtools.toolbox.alwaysOnTop"; +const DISABLE_AUTOHIDE_PREF = "ui.popup.disable_autohide"; +const PSEUDO_LOCALE_PREF = "intl.l10n.pseudo"; +const HOST_HISTOGRAM = "DEVTOOLS_TOOLBOX_HOST"; +const CURRENT_THEME_SCALAR = "devtools.current_theme"; +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const REGEX_4XX_5XX = /^[4,5]\d\d$/; + +const BROWSERTOOLBOX_SCOPE_PREF = "devtools.browsertoolbox.scope"; +const BROWSERTOOLBOX_SCOPE_EVERYTHING = "everything"; +const BROWSERTOOLBOX_SCOPE_PARENTPROCESS = "parent-process"; + +const { debounce } = require("resource://devtools/shared/debounce.js"); +const { throttle } = require("resource://devtools/shared/throttle.js"); +const { + safeAsyncMethod, +} = require("resource://devtools/shared/async-utils.js"); +var { gDevTools } = require("resource://devtools/client/framework/devtools.js"); +var EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const Selection = require("resource://devtools/client/framework/selection.js"); +var Telemetry = require("resource://devtools/client/shared/telemetry.js"); +const { + getUnicodeUrl, +} = require("resource://devtools/client/shared/unicode-url.js"); +var { DOMHelpers } = require("resource://devtools/shared/dom-helpers.js"); +const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); +const { + FluentL10n, +} = require("resource://devtools/client/shared/fluent-l10n/fluent-l10n.js"); + +var Startup = Cc["@mozilla.org/devtools/startup-clh;1"].getService( + Ci.nsISupports +).wrappedJSObject; + +const { BrowserLoader } = ChromeUtils.import( + "resource://devtools/shared/loader/browser-loader.js" +); + +const { + MultiLocalizationHelper, +} = require("resource://devtools/shared/l10n.js"); +const L10N = new MultiLocalizationHelper( + "devtools/client/locales/toolbox.properties", + "chrome://branding/locale/brand.properties" +); + +loader.lazyRequireGetter( + this, + "registerStoreObserver", + "resource://devtools/client/shared/redux/subscriber.js", + true +); +loader.lazyRequireGetter( + this, + "createToolboxStore", + "resource://devtools/client/framework/store.js", + true +); +loader.lazyRequireGetter( + this, + ["registerWalkerListeners", "removeTarget"], + "resource://devtools/client/framework/actions/index.js", + true +); +loader.lazyRequireGetter( + this, + ["selectTarget"], + "resource://devtools/shared/commands/target/actions/targets.js", + true +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AppConstants: "resource://gre/modules/AppConstants.sys.mjs", +}); +loader.lazyRequireGetter(this, "flags", "resource://devtools/shared/flags.js"); +loader.lazyRequireGetter( + this, + "KeyShortcuts", + "resource://devtools/client/shared/key-shortcuts.js" +); +loader.lazyRequireGetter( + this, + "ZoomKeys", + "resource://devtools/client/shared/zoom-keys.js" +); +loader.lazyRequireGetter( + this, + "ToolboxButtons", + "resource://devtools/client/definitions.js", + true +); +loader.lazyRequireGetter( + this, + "SourceMapURLService", + "resource://devtools/client/framework/source-map-url-service.js", + true +); +loader.lazyRequireGetter( + this, + "BrowserConsoleManager", + "resource://devtools/client/webconsole/browser-console-manager.js", + true +); +loader.lazyRequireGetter( + this, + "viewSource", + "resource://devtools/client/shared/view-source.js" +); +loader.lazyRequireGetter( + this, + "buildHarLog", + "resource://devtools/client/netmonitor/src/har/har-builder-utils.js", + true +); +loader.lazyRequireGetter( + this, + "NetMonitorAPI", + "resource://devtools/client/netmonitor/src/api.js", + true +); +loader.lazyRequireGetter( + this, + "sortPanelDefinitions", + "resource://devtools/client/framework/toolbox-tabs-order-manager.js", + true +); +loader.lazyRequireGetter( + this, + "createEditContextMenu", + "resource://devtools/client/framework/toolbox-context-menu.js", + true +); +loader.lazyRequireGetter( + this, + "getSelectedTarget", + "resource://devtools/shared/commands/target/selectors/targets.js", + true +); +loader.lazyRequireGetter( + this, + "remoteClientManager", + "resource://devtools/client/shared/remote-debugging/remote-client-manager.js", + true +); +loader.lazyRequireGetter( + this, + "ResponsiveUIManager", + "resource://devtools/client/responsive/manager.js" +); +loader.lazyRequireGetter( + this, + "DevToolsUtils", + "resource://devtools/shared/DevToolsUtils.js" +); +loader.lazyRequireGetter( + this, + "NodePicker", + "resource://devtools/client/inspector/node-picker.js" +); + +loader.lazyGetter(this, "domNodeConstants", () => { + return require("resource://devtools/shared/dom-node-constants.js"); +}); + +loader.lazyRequireGetter( + this, + "NodeFront", + "resource://devtools/client/fronts/node.js", + true +); + +loader.lazyRequireGetter( + this, + "PICKER_TYPES", + "resource://devtools/shared/picker-constants.js" +); + +loader.lazyRequireGetter( + this, + "HarAutomation", + "resource://devtools/client/netmonitor/src/har/har-automation.js", + true +); + +loader.lazyRequireGetter( + this, + "getThreadOptions", + "resource://devtools/client/shared/thread-utils.js", + true +); +loader.lazyRequireGetter( + this, + "SourceMapLoader", + "resource://devtools/client/shared/source-map-loader/index.js", + true +); +loader.lazyRequireGetter( + this, + "openProfilerTab", + "resource://devtools/client/performance-new/shared/browser.js", + true +); +loader.lazyGetter(this, "ProfilerBackground", () => { + return ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/background.sys.mjs" + ); +}); + +/** + * A "Toolbox" is the component that holds all the tools for one specific + * target. Visually, it's a document that includes the tools tabs and all + * the iframes where the tool panels will be living in. + * + * @param {object} commands + * The context to inspect identified by this commands. + * @param {string} selectedTool + * Tool to select initially + * @param {Toolbox.HostType} hostType + * Type of host that will host the toolbox (e.g. sidebar, window) + * @param {DOMWindow} contentWindow + * The window object of the toolbox document + * @param {string} frameId + * A unique identifier to differentiate toolbox documents from the + * chrome codebase when passing DOM messages + */ +function Toolbox(commands, selectedTool, hostType, contentWindow, frameId) { + this._win = contentWindow; + this.frameId = frameId; + this.selection = new Selection(); + this.telemetry = new Telemetry({ useSessionId: true }); + // This attribute helps identify one particular toolbox instance. + this.sessionId = this.telemetry.sessionId; + + // This attribute is meant to be a public attribute on the Toolbox object + // It exposes commands modules listed in devtools/shared/commands/index.js + // which are an abstraction on top of RDP methods. + // See devtools/shared/commands/README.md + this.commands = commands; + this._descriptorFront = commands.descriptorFront; + + // Map of the available DevTools WebExtensions: + // Map<extensionUUID, extensionName> + this._webExtensions = new Map(); + + this._toolPanels = new Map(); + this._inspectorExtensionSidebars = new Map(); + + this._netMonitorAPI = null; + + // Map of frames (id => frame-info) and currently selected frame id. + this.frameMap = new Map(); + this.selectedFrameId = null; + + // Number of targets currently paused + this._pausedTargets = 0; + + /** + * KeyShortcuts instance specific to WINDOW host type. + * This is the key shortcuts that are only register when the toolbox + * is loaded in its own window. Otherwise, these shortcuts are typically + * registered by devtools-startup.js module. + */ + this._windowHostShortcuts = null; + + this._toolRegistered = this._toolRegistered.bind(this); + this._toolUnregistered = this._toolUnregistered.bind(this); + this._refreshHostTitle = this._refreshHostTitle.bind(this); + this.toggleNoAutohide = this.toggleNoAutohide.bind(this); + this.toggleAlwaysOnTop = this.toggleAlwaysOnTop.bind(this); + this.disablePseudoLocale = () => this.changePseudoLocale("none"); + this.enableAccentedPseudoLocale = () => this.changePseudoLocale("accented"); + this.enableBidiPseudoLocale = () => this.changePseudoLocale("bidi"); + this._updateFrames = this._updateFrames.bind(this); + this._splitConsoleOnKeypress = this._splitConsoleOnKeypress.bind(this); + this.closeToolbox = this.closeToolbox.bind(this); + this.destroy = this.destroy.bind(this); + this._applyCacheSettings = this._applyCacheSettings.bind(this); + this._applyCustomFormatterSetting = + this._applyCustomFormatterSetting.bind(this); + this._applyServiceWorkersTestingSettings = + this._applyServiceWorkersTestingSettings.bind(this); + this._applySimpleHighlightersSettings = + this._applySimpleHighlightersSettings.bind(this); + this._saveSplitConsoleHeight = this._saveSplitConsoleHeight.bind(this); + this._onFocus = this._onFocus.bind(this); + this._onBlur = this._onBlur.bind(this); + this._onBrowserMessage = this._onBrowserMessage.bind(this); + this._onTabsOrderUpdated = this._onTabsOrderUpdated.bind(this); + this._onToolbarFocus = this._onToolbarFocus.bind(this); + this._onToolbarArrowKeypress = this._onToolbarArrowKeypress.bind(this); + this._onPickerClick = this._onPickerClick.bind(this); + this._onPickerKeypress = this._onPickerKeypress.bind(this); + this._onPickerStarting = this._onPickerStarting.bind(this); + this._onPickerStarted = this._onPickerStarted.bind(this); + this._onPickerStopped = this._onPickerStopped.bind(this); + this._onPickerCanceled = this._onPickerCanceled.bind(this); + this._onPickerPicked = this._onPickerPicked.bind(this); + this._onPickerPreviewed = this._onPickerPreviewed.bind(this); + this._onInspectObject = this._onInspectObject.bind(this); + this._onNewSelectedNodeFront = this._onNewSelectedNodeFront.bind(this); + this._onToolSelected = this._onToolSelected.bind(this); + this._onContextMenu = this._onContextMenu.bind(this); + this._onMouseDown = this._onMouseDown.bind(this); + this.updateToolboxButtonsVisibility = + this.updateToolboxButtonsVisibility.bind(this); + this.updateToolboxButtons = this.updateToolboxButtons.bind(this); + this.selectTool = this.selectTool.bind(this); + this._pingTelemetrySelectTool = this._pingTelemetrySelectTool.bind(this); + this.toggleSplitConsole = this.toggleSplitConsole.bind(this); + this.toggleOptions = this.toggleOptions.bind(this); + this._onTargetAvailable = this._onTargetAvailable.bind(this); + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + this._onTargetSelected = this._onTargetSelected.bind(this); + this._onResourceAvailable = this._onResourceAvailable.bind(this); + this._onResourceUpdated = this._onResourceUpdated.bind(this); + this._onToolSelectedStopPicker = this._onToolSelectedStopPicker.bind(this); + + // `component` might be null if the toolbox was destroying during the throttling + this._throttledSetToolboxButtons = throttle( + () => this.component?.setToolboxButtons(this.toolbarButtons), + 500, + this + ); + + this._debounceUpdateFocusedState = debounce( + () => { + this.component?.setFocusedState(this._isToolboxFocused); + }, + 500, + this + ); + + if (!selectedTool) { + selectedTool = Services.prefs.getCharPref(this._prefs.LAST_TOOL); + } + this._defaultToolId = selectedTool; + + this._hostType = hostType; + + this.isOpen = new Promise( + function (resolve) { + this._resolveIsOpen = resolve; + }.bind(this) + ); + + EventEmitter.decorate(this); + + this.on("host-changed", this._refreshHostTitle); + this.on("select", this._onToolSelected); + + this.selection.on("new-node-front", this._onNewSelectedNodeFront); + + gDevTools.on("tool-registered", this._toolRegistered); + gDevTools.on("tool-unregistered", this._toolUnregistered); + + /** + * Get text direction for the current locale direction. + * + * `getComputedStyle` forces a synchronous reflow, so use a lazy getter in order to + * call it only once. + */ + loader.lazyGetter(this, "direction", () => { + const { documentElement } = this.doc; + const isRtl = + this.win.getComputedStyle(documentElement).direction === "rtl"; + return isRtl ? "rtl" : "ltr"; + }); +} +exports.Toolbox = Toolbox; + +/** + * The toolbox can be 'hosted' either embedded in a browser window + * or in a separate window. + */ +Toolbox.HostType = { + BOTTOM: "bottom", + RIGHT: "right", + LEFT: "left", + WINDOW: "window", + BROWSERTOOLBOX: "browsertoolbox", + // This is typically used by `about:debugging`, when opening toolbox in a new tab, + // via `about:devtools-toolbox` URLs. + PAGE: "page", +}; + +Toolbox.prototype = { + _URL: "about:devtools-toolbox", + + _prefs: { + LAST_TOOL: "devtools.toolbox.selectedTool", + }, + + get nodePicker() { + if (!this._nodePicker) { + this._nodePicker = new NodePicker(this.commands, this.selection); + this._nodePicker.on("picker-starting", this._onPickerStarting); + this._nodePicker.on("picker-started", this._onPickerStarted); + this._nodePicker.on("picker-stopped", this._onPickerStopped); + this._nodePicker.on("picker-node-canceled", this._onPickerCanceled); + this._nodePicker.on("picker-node-picked", this._onPickerPicked); + this._nodePicker.on("picker-node-previewed", this._onPickerPreviewed); + } + + return this._nodePicker; + }, + + get store() { + if (!this._store) { + this._store = createToolboxStore(); + } + return this._store; + }, + + get currentToolId() { + return this._currentToolId; + }, + + set currentToolId(id) { + this._currentToolId = id; + this.component.setCurrentToolId(id); + }, + + get defaultToolId() { + return this._defaultToolId; + }, + + get panelDefinitions() { + return this._panelDefinitions; + }, + + set panelDefinitions(definitions) { + this._panelDefinitions = definitions; + this._combineAndSortPanelDefinitions(); + }, + + get visibleAdditionalTools() { + if (!this._visibleAdditionalTools) { + this._visibleAdditionalTools = []; + } + + return this._visibleAdditionalTools; + }, + + set visibleAdditionalTools(tools) { + this._visibleAdditionalTools = tools; + if (this.isReady) { + this._combineAndSortPanelDefinitions(); + } + }, + + /** + * Combines the built-in panel definitions and the additional tool definitions that + * can be set by add-ons. + */ + _combineAndSortPanelDefinitions() { + let definitions = [ + ...this._panelDefinitions, + ...this.getVisibleAdditionalTools(), + ]; + definitions = sortPanelDefinitions(definitions); + this.component.setPanelDefinitions(definitions); + }, + + lastUsedToolId: null, + + /** + * Returns a *copy* of the _toolPanels collection. + * + * @return {Map} panels + * All the running panels in the toolbox + */ + getToolPanels() { + return new Map(this._toolPanels); + }, + + /** + * Access the panel for a given tool + */ + getPanel(id) { + return this._toolPanels.get(id); + }, + + /** + * Get the panel instance for a given tool once it is ready. + * If the tool is already opened, the promise will resolve immediately, + * otherwise it will wait until the tool has been opened before resolving. + * + * Note that this does not open the tool, use selectTool if you'd + * like to select the tool right away. + * + * @param {String} id + * The id of the panel, for example "jsdebugger". + * @returns Promise + * A promise that resolves once the panel is ready. + */ + getPanelWhenReady(id) { + const panel = this.getPanel(id); + return new Promise(resolve => { + if (panel) { + resolve(panel); + } else { + this.on(id + "-ready", initializedPanel => { + resolve(initializedPanel); + }); + } + }); + }, + + /** + * This is a shortcut for getPanel(currentToolId) because it is much more + * likely that we're going to want to get the panel that we've just made + * visible + */ + getCurrentPanel() { + return this._toolPanels.get(this.currentToolId); + }, + + /** + * Get the current top level target the toolbox is debugging. + * + * This will only be defined *after* calling Toolbox.open(), + * after it has called `targetCommands.startListening`. + */ + get target() { + return this.commands.targetCommand.targetFront; + }, + + get threadFront() { + return this.commands.targetCommand.targetFront.threadFront; + }, + + /** + * Get/alter the host of a Toolbox, i.e. is it in browser or in a separate + * tab. See HostType for more details. + */ + get hostType() { + return this._hostType; + }, + + /** + * Shortcut to the window containing the toolbox UI + */ + get win() { + return this._win; + }, + + /** + * When the toolbox is loaded in a frame with type="content", win.parent will not return + * the parent Chrome window. This getter should return the parent Chrome window + * regardless of the frame type. See Bug 1539979. + */ + get topWindow() { + return DevToolsUtils.getTopWindow(this.win); + }, + + get topDoc() { + return this.topWindow.document; + }, + + /** + * Shortcut to the document containing the toolbox UI + */ + get doc() { + return this.win.document; + }, + + /** + * Get the toggled state of the split console + */ + get splitConsole() { + return this._splitConsole; + }, + + /** + * Get the focused state of the split console + */ + isSplitConsoleFocused() { + if (!this._splitConsole) { + return false; + } + const focusedWin = Services.focus.focusedWindow; + return ( + focusedWin && + focusedWin === + this.doc.querySelector("#toolbox-panel-iframe-webconsole").contentWindow + ); + }, + + get isBrowserToolbox() { + return this.hostType === Toolbox.HostType.BROWSERTOOLBOX; + }, + + get isMultiProcessBrowserToolbox() { + return this.isBrowserToolbox; + }, + + /** + * Set a given target as selected (which may impact the console evaluation context selector). + * + * @param {String} targetActorID: The actorID of the target we want to select. + */ + selectTarget(targetActorID) { + if (this.getSelectedTargetFront()?.actorID !== targetActorID) { + // The selected target is managed by the TargetCommand's store. + // So dispatch this action against that other store. + this.commands.targetCommand.store.dispatch(selectTarget(targetActorID)); + } + }, + + /** + * @returns {ThreadFront|null} The selected thread front, or null if there is none. + */ + getSelectedTargetFront() { + // The selected target is managed by the TargetCommand's store. + // So pull the state from that other store. + const selectedTarget = getSelectedTarget( + this.commands.targetCommand.store.getState() + ); + if (!selectedTarget) { + return null; + } + + return this.commands.client.getFrontByID(selectedTarget.actorID); + }, + + /** + * For now, the debugger isn't hooked to TargetCommand's store + * to display its thread list. So manually forward target selection change + * to the debugger via a dedicated action + */ + _onTargetCommandStateChange(state, oldState) { + if (getSelectedTarget(state) !== getSelectedTarget(oldState)) { + const dbg = this.getPanel("jsdebugger"); + if (!dbg) { + return; + } + + const threadActorID = getSelectedTarget(state)?.threadFront?.actorID; + if (!threadActorID) { + return; + } + + dbg.selectThread(threadActorID); + } + }, + + /** + * Called on each new THREAD_STATE resource + * + * @param {Object} resource The THREAD_STATE resource + */ + _onThreadStateChanged(resource) { + if (resource.state == "paused") { + this._pauseToolbox(resource.why.type); + } else if (resource.state == "resumed") { + this._resumeToolbox(); + } + }, + + /** + * Called on each new JSTRACER_STATE resource + * + * @param {Object} resource The JSTRACER_STATE resource + */ + async _onTracingStateChanged(resource) { + const { profile } = resource; + if (!profile) { + return; + } + const browser = await openProfilerTab(); + + const profileCaptureResult = { + type: "SUCCESS", + profile, + }; + ProfilerBackground.registerProfileCaptureForBrowser( + browser, + profileCaptureResult, + null + ); + }, + + /** + * Be careful, this method is synchronous, but highlightTool, raise, selectTool + * are all async. + */ + _pauseToolbox(reason) { + // Suppress interrupted events by default because the thread is + // paused/resumed a lot for various actions. + if (reason === "interrupted") { + return; + } + + this.highlightTool("jsdebugger"); + + if ( + reason === "debuggerStatement" || + reason === "mutationBreakpoint" || + reason === "eventBreakpoint" || + reason === "breakpoint" || + reason === "exception" || + reason === "resumeLimit" || + reason === "XHR" || + reason === "breakpointConditionThrown" + ) { + this.raise(); + this.selectTool("jsdebugger", reason); + // Each Target/Thread can be paused only once at a time, + // so, for each pause, we should have a related resumed event. + // But we may have multiple targets paused at the same time + this._pausedTargets++; + this.emit("toolbox-paused"); + } + }, + + _resumeToolbox() { + if (this.isHighlighted("jsdebugger")) { + this._pausedTargets--; + if (this._pausedTargets == 0) { + this.emit("toolbox-resumed"); + this.unhighlightTool("jsdebugger"); + } + } + }, + + /** + * This method will be called for the top-level target, as well as any potential + * additional targets we may care about. + */ + async _onTargetAvailable({ targetFront, isTargetSwitching }) { + if (targetFront.isTopLevel) { + // Attach to a new top-level target. + // For now, register these event listeners only on the top level target + if (!targetFront.targetForm.ignoreSubFrames) { + targetFront.on("frame-update", this._updateFrames); + } + const consoleFront = await targetFront.getFront("console"); + consoleFront.on("inspectObject", this._onInspectObject); + } + + // Walker listeners allow to monitor DOM Mutation breakpoint updates. + // All targets should be monitored. + targetFront.watchFronts("inspector", async inspectorFront => { + registerWalkerListeners(this.store, inspectorFront.walker); + }); + + if (targetFront.isTopLevel && isTargetSwitching) { + // These methods expect the target to be attached, which is guaranteed by the time + // _onTargetAvailable is called by the targetCommand. + await this._listFrames(); + // The target may have been destroyed while calling _listFrames if we navigate quickly + if (targetFront.isDestroyed()) { + return; + } + } + + if (targetFront.targetForm.ignoreSubFrames) { + this._updateFrames({ + frames: [ + { + id: targetFront.actorID, + targetFront, + url: targetFront.url, + title: targetFront.title, + isTopLevel: targetFront.isTopLevel, + }, + ], + }); + } + + // If a new popup is debugged, automagically switch the toolbox to become + // an independant window so that we can easily keep debugging the new tab. + // Only do that if that's not the current top level, otherwise it means + // we opened a toolbox dedicated to the popup. + if ( + targetFront.targetForm.isPopup && + !targetFront.isTopLevel && + this._descriptorFront.isLocalTab + ) { + await this.switchHostToTab(targetFront.targetForm.browsingContextID); + } + }, + + async _onTargetSelected({ targetFront }) { + this._updateFrames({ selected: targetFront.actorID }); + this.selectTarget(targetFront.actorID); + }, + + _onTargetDestroyed({ targetFront }) { + removeTarget(this.store, targetFront); + + if (targetFront.isTopLevel) { + const consoleFront = targetFront.getCachedFront("console"); + // If the target has already been destroyed, its console front will + // also already be destroyed and so we won't be able to retrieve it. + // Nor is it important to clear its listener as fronts automatically clears + // all their listeners on destroy. + if (consoleFront) { + consoleFront.off("inspectObject", this._onInspectObject); + } + targetFront.off("frame-update", this._updateFrames); + } else if (this.selection) { + this.selection.onTargetDestroyed(targetFront); + } + + // When navigating the old (top level) target can get destroyed before the thread state changed + // event for the target is received, so it gets lost. This currently happens with bf-cache + // navigations when paused, so lets make sure we resumed if not. + // + // We should also resume if a paused non-top-level target is destroyed + if (targetFront.isTopLevel || targetFront.threadFront?.paused) { + this._resumeToolbox(); + } + + if (targetFront.targetForm.ignoreSubFrames) { + this._updateFrames({ + frames: [ + { + id: targetFront.actorID, + destroy: true, + }, + ], + }); + } + }, + + _onTargetThreadFrontResumeWrongOrder() { + const box = this.getNotificationBox(); + box.appendNotification( + L10N.getStr("toolbox.resumeOrderWarning"), + "wrong-resume-order", + "", + box.PRIORITY_WARNING_HIGH + ); + }, + + /** + * Open the toolbox + */ + open() { + return async function () { + // Kick off async loading the Fluent bundles. + const fluentL10n = new FluentL10n(); + const fluentInitPromise = fluentL10n.init([ + "devtools/client/toolbox.ftl", + ]); + + const isToolboxURL = this.win.location.href.startsWith(this._URL); + if (isToolboxURL) { + // Update the URL so that onceDOMReady watch for the right url. + this._URL = this.win.location.href; + } + + const domReady = new Promise(resolve => { + DOMHelpers.onceDOMReady( + this.win, + () => { + resolve(); + }, + this._URL + ); + }); + + this.commands.targetCommand.on( + "target-thread-wrong-order-on-resume", + this._onTargetThreadFrontResumeWrongOrder.bind(this) + ); + registerStoreObserver( + this.commands.targetCommand.store, + this._onTargetCommandStateChange.bind(this) + ); + + // Bug 1709063: Use commands.resourceCommand instead of toolbox.resourceCommand + this.resourceCommand = this.commands.resourceCommand; + + // Optimization: fire up a few other things before waiting on + // the iframe being ready (makes startup faster) + await this.commands.targetCommand.startListening(); + + // Lets get the current thread settings from the prefs and + // update the threadConfigurationActor which should manage + // updating the current threads. + const options = await getThreadOptions(); + await this.commands.threadConfigurationCommand.updateConfiguration( + options + ); + + // This needs to be done before watching for resources so console messages can be + // custom formatted right away. + await this._applyCustomFormatterSetting(); + + // The targetCommand is created right before this code. + // It means that this call to watchTargets is the first, + // and we are registering the first target listener, which means + // Toolbox._onTargetAvailable will be called first, before any other + // onTargetAvailable listener that might be registered on targetCommand. + await this.commands.targetCommand.watchTargets({ + types: this.commands.targetCommand.ALL_TYPES, + onAvailable: this._onTargetAvailable, + onSelected: this._onTargetSelected, + onDestroyed: this._onTargetDestroyed, + }); + + const watchedResources = [ + // Watch for console API messages, errors and network events in order to populate + // the error count icon in the toolbox. + this.resourceCommand.TYPES.CONSOLE_MESSAGE, + this.resourceCommand.TYPES.ERROR_MESSAGE, + this.resourceCommand.TYPES.DOCUMENT_EVENT, + this.resourceCommand.TYPES.THREAD_STATE, + ]; + + let tracerInitialization; + if ( + Services.prefs.getBoolPref( + "devtools.debugger.features.javascript-tracing", + false + ) + ) { + watchedResources.push(this.resourceCommand.TYPES.JSTRACER_STATE); + tracerInitialization = this.commands.tracerCommand.initialize(); + } + + if (!this.isBrowserToolbox) { + // Independently of watching network event resources for the error count icon, + // we need to start tracking network activity on toolbox open for targets such + // as tabs, in order to ensure there is always at least one listener existing + // for network events across the lifetime of the various panels, so stopping + // the resource command from clearing out its cache of network event resources. + watchedResources.push(this.resourceCommand.TYPES.NETWORK_EVENT); + } + + const onResourcesWatched = this.resourceCommand.watchResources( + watchedResources, + { + onAvailable: this._onResourceAvailable, + onUpdated: this._onResourceUpdated, + } + ); + + await domReady; + + this.browserRequire = BrowserLoader({ + window: this.win, + useOnlyShared: true, + }).require; + + this.isReady = true; + + const framesPromise = this._listFrames(); + + Services.prefs.addObserver( + "devtools.cache.disabled", + this._applyCacheSettings + ); + Services.prefs.addObserver( + "devtools.custom-formatters.enabled", + this._applyCustomFormatterSetting + ); + Services.prefs.addObserver( + "devtools.serviceWorkers.testing.enabled", + this._applyServiceWorkersTestingSettings + ); + Services.prefs.addObserver( + "devtools.inspector.simple-highlighters-reduced-motion", + this._applySimpleHighlightersSettings + ); + Services.prefs.addObserver( + BROWSERTOOLBOX_SCOPE_PREF, + this._refreshHostTitle + ); + + // Get the DOM element to mount the ToolboxController to. + this._componentMount = this.doc.getElementById("toolbox-toolbar-mount"); + + await fluentInitPromise; + + // Mount the ToolboxController component and update all its state + // that can be updated synchronousl + this._mountReactComponent(fluentL10n.getBundles()); + this._buildDockOptions(); + this._buildInitialPanelDefinitions(); + this._setDebugTargetData(); + + // Forward configuration flags to the DevTools server. + this._applyCacheSettings(); + this._applyServiceWorkersTestingSettings(); + this._applySimpleHighlightersSettings(); + + this._addWindowListeners(); + this._addChromeEventHandlerEvents(); + + // Get the tab bar of the ToolboxController to attach the "keypress" event listener to. + this._tabBar = this.doc.querySelector(".devtools-tabbar"); + this._tabBar.addEventListener("keypress", this._onToolbarArrowKeypress); + + this._componentMount.setAttribute( + "aria-label", + L10N.getStr("toolbox.label") + ); + + this.webconsolePanel = this.doc.querySelector( + "#toolbox-panel-webconsole" + ); + this.webconsolePanel.style.height = + Services.prefs.getIntPref(SPLITCONSOLE_HEIGHT_PREF) + "px"; + this.webconsolePanel.addEventListener( + "resize", + this._saveSplitConsoleHeight + ); + + this._buildButtons(); + + this._pingTelemetry(); + + // The isToolSupported check needs to happen after the target is + // remoted, otherwise we could have done it in the toolbox constructor + // (bug 1072764). + const toolDef = gDevTools.getToolDefinition(this._defaultToolId); + if (!toolDef || !toolDef.isToolSupported(this)) { + this._defaultToolId = "webconsole"; + } + + // Update all ToolboxController state that can only be done asynchronously + await this._setInitialMeatballState(); + + // Start rendering the toolbox toolbar before selecting the tool, as the tools + // can take a few hundred milliseconds seconds to start up. + // + // Delay React rendering as Toolbox.open is synchronous. + // Even if this involve promises, it is synchronous. Toolbox.open already loads + // react modules and freeze the event loop for a significant time. + // requestIdleCallback allows releasing it to allow user events to be processed. + // Use 16ms maximum delay to allow one frame to be rendered at 60FPS + // (1000ms/60FPS=16ms) + this.win.requestIdleCallback( + () => { + this.component.setCanRender(); + }, + { timeout: 16 } + ); + + await this.selectTool(this._defaultToolId, "initial_panel"); + + // Wait until the original tool is selected so that the split + // console input will receive focus. + let splitConsolePromise = Promise.resolve(); + if (Services.prefs.getBoolPref(SPLITCONSOLE_ENABLED_PREF)) { + splitConsolePromise = this.openSplitConsole(); + this.telemetry.addEventProperty( + this.topWindow, + "open", + "tools", + null, + "splitconsole", + true + ); + } else { + this.telemetry.addEventProperty( + this.topWindow, + "open", + "tools", + null, + "splitconsole", + false + ); + } + + await Promise.all([ + splitConsolePromise, + framesPromise, + onResourcesWatched, + tracerInitialization, + ]); + + // We do not expect the focus to be restored when using about:debugging toolboxes + // Otherwise, when reloading the toolbox, the debugged tab will be focused. + if (this.hostType !== Toolbox.HostType.PAGE) { + // Request the actor to restore the focus to the content page once the + // target is detached. This typically happens when the console closes. + // We restore the focus as it may have been stolen by the console input. + await this.commands.targetConfigurationCommand.updateConfiguration({ + restoreFocus: true, + }); + } + + await this.initHarAutomation(); + + this.emit("ready"); + this._resolveIsOpen(); + } + .bind(this)() + .catch(e => { + console.error("Exception while opening the toolbox", String(e), e); + // While the exception stack is correctly printed in the Browser console when + // passing `e` to console.error, it is not on the stdout, so print it via dump. + dump(e.stack + "\n"); + }); + }, + + /** + * Retrieve the ChromeEventHandler associated to the toolbox frame. + * When DevTools are loaded in a content frame, this will return the containing chrome + * frame. Events from nested frames will bubble up to this chrome frame, which allows to + * listen to events from nested frames. + */ + getChromeEventHandler() { + if (!this.win || !this.win.docShell) { + return null; + } + return this.win.docShell.chromeEventHandler; + }, + + /** + * Attach events on the chromeEventHandler for the current window. When loaded in a + * frame with type set to "content", events will not bubble across frames. The + * chromeEventHandler does not have this limitation and will catch all events triggered + * on any of the frames under the devtools document. + * + * Events relying on the chromeEventHandler need to be added and removed at specific + * moments in the lifecycle of the toolbox, so all the events relying on it should be + * grouped here. + */ + _addChromeEventHandlerEvents() { + // win.docShell.chromeEventHandler might not be accessible anymore when removing the + // events, so we can't rely on a dynamic getter here. + // Keep a reference on the chromeEventHandler used to addEventListener to be sure we + // can remove the listeners afterwards. + this._chromeEventHandler = this.getChromeEventHandler(); + if (!this._chromeEventHandler) { + return; + } + + // Add shortcuts and window-host-shortcuts that use the ChromeEventHandler as target. + this._addShortcuts(); + this._addWindowHostShortcuts(); + + this._chromeEventHandler.addEventListener( + "keypress", + this._splitConsoleOnKeypress + ); + this._chromeEventHandler.addEventListener("focus", this._onFocus, true); + this._chromeEventHandler.addEventListener("blur", this._onBlur, true); + this._chromeEventHandler.addEventListener( + "contextmenu", + this._onContextMenu + ); + this._chromeEventHandler.addEventListener("mousedown", this._onMouseDown); + }, + + _removeChromeEventHandlerEvents() { + if (!this._chromeEventHandler) { + return; + } + + // Remove shortcuts and window-host-shortcuts that use the ChromeEventHandler as + // target. + this._removeShortcuts(); + this._removeWindowHostShortcuts(); + + this._chromeEventHandler.removeEventListener( + "keypress", + this._splitConsoleOnKeypress + ); + this._chromeEventHandler.removeEventListener("focus", this._onFocus, true); + this._chromeEventHandler.removeEventListener("focus", this._onBlur, true); + this._chromeEventHandler.removeEventListener( + "contextmenu", + this._onContextMenu + ); + this._chromeEventHandler.removeEventListener( + "mousedown", + this._onMouseDown + ); + + this._chromeEventHandler = null; + }, + + _addShortcuts() { + // Create shortcuts instance for the toolbox + if (!this.shortcuts) { + this.shortcuts = new KeyShortcuts({ + window: this.doc.defaultView, + // The toolbox key shortcuts should be triggered from any frame in DevTools. + // Use the chromeEventHandler as the target to catch events from all frames. + target: this.getChromeEventHandler(), + }); + } + + // Listen for the shortcut key to show the frame list + this.shortcuts.on(L10N.getStr("toolbox.showFrames.key"), event => { + if (event.target.id === "command-button-frames") { + event.target.click(); + } + }); + + // Listen for tool navigation shortcuts. + this.shortcuts.on(L10N.getStr("toolbox.nextTool.key"), event => { + this.selectNextTool(); + event.preventDefault(); + }); + this.shortcuts.on(L10N.getStr("toolbox.previousTool.key"), event => { + this.selectPreviousTool(); + event.preventDefault(); + }); + this.shortcuts.on(L10N.getStr("toolbox.toggleHost.key"), event => { + this.switchToPreviousHost(); + event.preventDefault(); + }); + + // List for Help/Settings key. + this.shortcuts.on(L10N.getStr("toolbox.help.key"), this.toggleOptions); + + if (!this.isBrowserToolbox) { + // Listen for Reload shortcuts + [ + ["reload", false], + ["reload2", false], + ["forceReload", true], + ["forceReload2", true], + ].forEach(([id, force]) => { + const key = L10N.getStr("toolbox." + id + ".key"); + this.shortcuts.on(key, event => { + this.commands.targetCommand.reloadTopLevelTarget(force); + + // Prevent Firefox shortcuts from reloading the page + event.preventDefault(); + }); + }); + } + + // Add zoom-related shortcuts. + if (this.hostType != Toolbox.HostType.PAGE) { + // When the toolbox is rendered in a tab (ie host type is PAGE), the + // zoom should be handled by the default browser shortcuts. + ZoomKeys.register(this.win, this.shortcuts); + } + }, + + _removeShortcuts() { + if (this.shortcuts) { + this.shortcuts.destroy(); + this.shortcuts = null; + } + }, + + /** + * Adds the keys and commands to the Toolbox Window in window mode. + */ + _addWindowHostShortcuts() { + if (this.hostType != Toolbox.HostType.WINDOW) { + // Those shortcuts are only valid for host type WINDOW. + return; + } + + if (!this._windowHostShortcuts) { + this._windowHostShortcuts = new KeyShortcuts({ + window: this.win, + // The window host key shortcuts should be triggered from any frame in DevTools. + // Use the chromeEventHandler as the target to catch events from all frames. + target: this.getChromeEventHandler(), + }); + } + + const shortcuts = this._windowHostShortcuts; + + for (const item of Startup.KeyShortcuts) { + const { id, toolId, shortcut, modifiers } = item; + const electronKey = KeyShortcuts.parseXulKey(modifiers, shortcut); + + if (id == "browserConsole") { + // Add key for toggling the browser console from the detached window + shortcuts.on(electronKey, () => { + BrowserConsoleManager.toggleBrowserConsole(); + }); + } else if (toolId) { + // KeyShortcuts contain tool-specific and global key shortcuts, + // here we only need to copy shortcut specific to each tool. + shortcuts.on(electronKey, () => { + this.selectTool(toolId, "key_shortcut").then(() => + this.fireCustomKey(toolId) + ); + }); + } + } + + // CmdOrCtrl+W is registered only when the toolbox is running in + // detached window. In the other case the entire browser tab + // is closed when the user uses this shortcut. + shortcuts.on(L10N.getStr("toolbox.closeToolbox.key"), this.closeToolbox); + + // The others are only registered in window host type as for other hosts, + // these keys are already registered by devtools-startup.js + shortcuts.on( + L10N.getStr("toolbox.toggleToolboxF12.key"), + this.closeToolbox + ); + if (lazy.AppConstants.platform == "macosx") { + shortcuts.on( + L10N.getStr("toolbox.toggleToolboxOSX.key"), + this.closeToolbox + ); + } else { + shortcuts.on(L10N.getStr("toolbox.toggleToolbox.key"), this.closeToolbox); + } + }, + + _removeWindowHostShortcuts() { + if (this._windowHostShortcuts) { + this._windowHostShortcuts.destroy(); + this._windowHostShortcuts = null; + } + }, + + _onContextMenu(e) { + // Handle context menu events in standard input elements: <input> and <textarea>. + // Also support for custom input elements using .devtools-input class + // (e.g. CodeMirror instances). + const isInInput = + e.originalTarget.closest("input[type=text]") || + e.originalTarget.closest("input[type=search]") || + e.originalTarget.closest("input:not([type])") || + e.originalTarget.closest(".devtools-input") || + e.originalTarget.closest("textarea"); + + const doc = e.originalTarget.ownerDocument; + const isHTMLPanel = doc.documentElement.namespaceURI === HTML_NS; + + if ( + // Context-menu events on input elements will use a custom context menu. + isInInput || + // Context-menu events from HTML panels should not trigger the default + // browser context menu for HTML documents. + isHTMLPanel + ) { + e.stopPropagation(); + e.preventDefault(); + } + + if (isInInput) { + this.openTextBoxContextMenu(e.screenX, e.screenY); + } + }, + + _onMouseDown(e) { + const isMiddleClick = e.button === 1; + if (isMiddleClick) { + // Middle clicks will trigger the scroll lock feature to turn on. + // When the DevTools toolbox was running in an <iframe>, this behavior was + // disabled by default. When running in a <browser> element, we now need + // to catch and preventDefault() on those events. + e.preventDefault(); + } + }, + + _getDebugTargetData() { + const url = new URL(this.win.location); + const remoteId = url.searchParams.get("remoteId"); + const runtimeInfo = remoteClientManager.getRuntimeInfoByRemoteId(remoteId); + const connectionType = + remoteClientManager.getConnectionTypeByRemoteId(remoteId); + + return { + connectionType, + runtimeInfo, + descriptorType: this._descriptorFront.descriptorType, + }; + }, + + isDebugTargetFenix() { + return this._getDebugTargetData()?.runtimeInfo?.isFenix; + }, + + /** + * loading React modules when needed (to avoid performance penalties + * during Firefox start up time). + */ + get React() { + return this.browserRequire("devtools/client/shared/vendor/react"); + }, + + get ReactDOM() { + return this.browserRequire("devtools/client/shared/vendor/react-dom"); + }, + + get ReactRedux() { + return this.browserRequire("devtools/client/shared/vendor/react-redux"); + }, + + get ToolboxController() { + return this.browserRequire( + "devtools/client/framework/components/ToolboxController" + ); + }, + + /** + * A common access point for the client-side mapping service for source maps that + * any panel can use. This is a "low-level" API that connects to + * the source map worker. + */ + get sourceMapLoader() { + if (this._sourceMapLoader) { + return this._sourceMapLoader; + } + this._sourceMapLoader = new SourceMapLoader(this.commands.targetCommand); + return this._sourceMapLoader; + }, + + /** + * Expose the "Parser" debugger worker to both webconsole and debugger. + * + * Note that the Browser Console will also self-instantiate it as it doesn't involve a toolbox. + */ + get parserWorker() { + if (this._parserWorker) { + return this._parserWorker; + } + + const { + ParserDispatcher, + } = require("resource://devtools/client/debugger/src/workers/parser/index.js"); + + this._parserWorker = new ParserDispatcher(); + return this._parserWorker; + }, + + /** + * Clients wishing to use source maps but that want the toolbox to + * track the source and style sheet actor mapping can use this + * source map service. This is a higher-level service than the one + * returned by |sourceMapLoader|, in that it automatically tracks + * source and style sheet actor IDs. + */ + get sourceMapURLService() { + if (this._sourceMapURLService) { + return this._sourceMapURLService; + } + this._sourceMapURLService = new SourceMapURLService( + this.commands, + this.sourceMapLoader + ); + return this._sourceMapURLService; + }, + + // Return HostType id for telemetry + _getTelemetryHostId() { + switch (this.hostType) { + case Toolbox.HostType.BOTTOM: + return 0; + case Toolbox.HostType.RIGHT: + return 1; + case Toolbox.HostType.WINDOW: + return 2; + case Toolbox.HostType.BROWSERTOOLBOX: + return 3; + case Toolbox.HostType.LEFT: + return 4; + case Toolbox.HostType.PAGE: + return 5; + default: + return 9; + } + }, + + // Return HostType string for telemetry + _getTelemetryHostString() { + switch (this.hostType) { + case Toolbox.HostType.BOTTOM: + return "bottom"; + case Toolbox.HostType.LEFT: + return "left"; + case Toolbox.HostType.RIGHT: + return "right"; + case Toolbox.HostType.WINDOW: + return "window"; + case Toolbox.HostType.PAGE: + return "page"; + case Toolbox.HostType.BROWSERTOOLBOX: + return "other"; + default: + return "bottom"; + } + }, + + _pingTelemetry() { + Services.prefs.setBoolPref("devtools.everOpened", true); + this.telemetry.toolOpened("toolbox", this); + + this.telemetry + .getHistogramById(HOST_HISTOGRAM) + .add(this._getTelemetryHostId()); + + // Log current theme. The question we want to answer is: + // "What proportion of users use which themes?" + const currentTheme = Services.prefs.getCharPref("devtools.theme"); + this.telemetry.keyedScalarAdd(CURRENT_THEME_SCALAR, currentTheme, 1); + + const browserWin = this.topWindow; + this.telemetry.preparePendingEvent(browserWin, "open", "tools", null, [ + "entrypoint", + "first_panel", + "host", + "shortcut", + "splitconsole", + "width", + ]); + this.telemetry.addEventProperty( + browserWin, + "open", + "tools", + null, + "host", + this._getTelemetryHostString() + ); + }, + + /** + * Create a simple object to store the state of a toolbox button. The checked state of + * a button can be updated arbitrarily outside of the scope of the toolbar and its + * controllers. In order to simplify this interaction this object emits an + * "updatechecked" event any time the isChecked value is updated, allowing any consuming + * components to listen and respond to updates. + * + * @param {Object} options: + * + * @property {String} id - The id of the button or command. + * @property {String} className - An optional additional className for the button. + * @property {String} description - The value that will display as a tooltip and in + * the options panel for enabling/disabling. + * @property {Boolean} disabled - An optional disabled state for the button. + * @property {Function} onClick - The function to run when the button is activated by + * click or keyboard shortcut. First argument will be the 'click' + * event, and second argument is the toolbox instance. + * @property {Boolean} isInStartContainer - Buttons can either be placed at the start + * of the toolbar, or at the end. + * @property {Function} setup - Function run immediately to listen for events changing + * whenever the button is checked or unchecked. The toolbox object + * is passed as first argument and a callback is passed as second + * argument, to be called whenever the checked state changes. + * @property {Function} teardown - Function run on toolbox close to let a chance to + * unregister listeners set when `setup` was called and avoid + * memory leaks. The same arguments than `setup` function are + * passed to `teardown`. + * @property {Function} isToolSupported - Function to automatically enable/disable + * the button based on the toolbox. If the toolbox don't support + * the button feature, this method should return false. + * @property {Function} isCurrentlyVisible - Function to automatically + * hide/show the button based on current state. + * @property {Function} isChecked - Optional function called to known if the button + * is toggled or not. The function should return true when + * the button should be displayed as toggled on. + */ + _createButtonState(options) { + let isCheckedValue = false; + const { + id, + className, + description, + disabled, + onClick, + isInStartContainer, + setup, + teardown, + isToolSupported, + isCurrentlyVisible, + isChecked, + isToggle, + onKeyDown, + experimentalURL, + } = options; + const toolbox = this; + const button = { + id, + className, + description, + disabled, + async onClick(event) { + if (typeof onClick == "function") { + await onClick(event, toolbox); + button.emit("updatechecked"); + } + }, + onKeyDown(event) { + if (typeof onKeyDown == "function") { + onKeyDown(event, toolbox); + } + }, + isToolSupported, + isCurrentlyVisible, + get isChecked() { + if (typeof isChecked == "function") { + return isChecked(toolbox); + } + return isCheckedValue; + }, + set isChecked(value) { + // Note that if options.isChecked is given, this is ignored + isCheckedValue = value; + this.emit("updatechecked"); + }, + isToggle, + // The preference for having this button visible. + visibilityswitch: `devtools.${id}.enabled`, + // The toolbar has a container at the start and end of the toolbar for + // holding buttons. By default the buttons are placed in the end container. + isInStartContainer: !!isInStartContainer, + experimentalURL, + }; + if (typeof setup == "function") { + const onChange = () => { + button.emit("updatechecked"); + }; + setup(this, onChange); + // Save a reference to the cleanup method that will unregister the onChange + // callback. Immediately bind the function argument so that we don't have to + // also save a reference to them. + button.teardown = teardown.bind(options, this, onChange); + } + button.isVisible = this._commandIsVisible(button); + + EventEmitter.decorate(button); + + return button; + }, + + _splitConsoleOnKeypress(e) { + if (e.keyCode !== KeyCodes.DOM_VK_ESCAPE) { + return; + } + + const currentPanel = this.getCurrentPanel(); + if ( + typeof currentPanel.onToolboxChromeEventHandlerEscapeKeyDown === + "function" + ) { + const ac = new this.win.AbortController(); + currentPanel.onToolboxChromeEventHandlerEscapeKeyDown(ac); + if (ac.signal.aborted) { + return; + } + } + + this.toggleSplitConsole(); + // If the debugger is paused, don't let the ESC key stop any pending navigation. + // If the host is page, don't let the ESC stop the load of the webconsole frame. + if ( + this.threadFront.state == "paused" || + this.hostType === Toolbox.HostType.PAGE + ) { + e.preventDefault(); + } + }, + + /** + * Add a shortcut key that should work when a split console + * has focus to the toolbox. + * + * @param {String} key + * The electron key shortcut. + * @param {Function} handler + * The callback that should be called when the provided key shortcut is pressed. + * @param {String} whichTool + * The tool the key belongs to. The corresponding handler will only be triggered + * if this tool is active. + */ + useKeyWithSplitConsole(key, handler, whichTool) { + this.shortcuts.on(key, event => { + if (this.currentToolId === whichTool && this.isSplitConsoleFocused()) { + handler(); + event.preventDefault(); + } + }); + }, + + _addWindowListeners() { + this.win.addEventListener("unload", this.destroy); + this.win.addEventListener("message", this._onBrowserMessage, true); + }, + + _removeWindowListeners() { + // The host iframe's contentDocument may already be gone. + if (this.win) { + this.win.removeEventListener("unload", this.destroy); + this.win.removeEventListener("message", this._onBrowserMessage, true); + } + }, + + // Called whenever the chrome send a message + _onBrowserMessage(event) { + if (event.data?.name === "switched-host") { + this._onSwitchedHost(event.data); + } + if (event.data?.name === "switched-host-to-tab") { + this._onSwitchedHostToTab(event.data.browsingContextID); + } + if (event.data?.name === "host-raised") { + this.emit("host-raised"); + } + }, + + _saveSplitConsoleHeight() { + const height = parseInt(this.webconsolePanel.style.height, 10); + if (!isNaN(height)) { + Services.prefs.setIntPref(SPLITCONSOLE_HEIGHT_PREF, height); + } + }, + + /** + * Make sure that the console is showing up properly based on all the + * possible conditions. + * 1) If the console tab is selected, then regardless of split state + * it should take up the full height of the deck, and we should + * hide the deck and splitter. + * 2) If the console tab is not selected and it is split, then we should + * show the splitter, deck, and console. + * 3) If the console tab is not selected and it is *not* split, + * then we should hide the console and splitter, and show the deck + * at full height. + */ + _refreshConsoleDisplay() { + const deck = this.doc.getElementById("toolbox-deck"); + const webconsolePanel = this.webconsolePanel; + const splitter = this.doc.getElementById("toolbox-console-splitter"); + const openedConsolePanel = this.currentToolId === "webconsole"; + + if (openedConsolePanel) { + deck.collapsed = true; + deck.removeAttribute("expanded"); + splitter.hidden = true; + webconsolePanel.collapsed = false; + webconsolePanel.setAttribute("expanded", ""); + } else { + deck.collapsed = false; + deck.toggleAttribute("expanded", !this.splitConsole); + splitter.hidden = !this.splitConsole; + webconsolePanel.collapsed = !this.splitConsole; + webconsolePanel.removeAttribute("expanded"); + } + }, + + /** + * Handle any custom key events. Returns true if there was a custom key + * binding run. + * @param {string} toolId Which tool to run the command on (skip if not + * current) + */ + fireCustomKey(toolId) { + const toolDefinition = gDevTools.getToolDefinition(toolId); + + if ( + toolDefinition.onkey && + (this.currentToolId === toolId || + (toolId == "webconsole" && this.splitConsole)) + ) { + toolDefinition.onkey(this.getCurrentPanel(), this); + } + }, + + /** + * Build the notification box as soon as needed. + */ + get notificationBox() { + if (!this._notificationBox) { + let { NotificationBox, PriorityLevels } = this.browserRequire( + "devtools/client/shared/components/NotificationBox" + ); + + NotificationBox = this.React.createFactory(NotificationBox); + + // Render NotificationBox and assign priority levels to it. + const box = this.doc.getElementById("toolbox-notificationbox"); + this._notificationBox = Object.assign( + this.ReactDOM.render(NotificationBox({}), box), + PriorityLevels + ); + } + return this._notificationBox; + }, + + /** + * Build the options for changing hosts. Called every time + * the host changes. + */ + _buildDockOptions() { + if (!this._descriptorFront.isLocalTab) { + this.component.setDockOptionsEnabled(false); + this.component.setCanCloseToolbox(false); + return; + } + + this.component.setDockOptionsEnabled(true); + this.component.setCanCloseToolbox( + this.hostType !== Toolbox.HostType.WINDOW + ); + + const hostTypes = []; + for (const type in Toolbox.HostType) { + const position = Toolbox.HostType[type]; + if ( + position == Toolbox.HostType.BROWSERTOOLBOX || + position == Toolbox.HostType.PAGE + ) { + continue; + } + + hostTypes.push({ + position, + switchHost: this.switchHost.bind(this, position), + }); + } + + this.component.setCurrentHostType(this.hostType); + this.component.setHostTypes(hostTypes); + }, + + postMessage(msg) { + // We sometime try to send messages in middle of destroy(), where the + // toolbox iframe may already be detached. + if (!this._destroyer) { + // Toolbox document is still chrome and disallow identifying message + // origin via event.source as it is null. So use a custom id. + msg.frameId = this.frameId; + this.topWindow.postMessage(msg, "*"); + } + }, + + /** + * This will fetch the panel definitions from the constants in definitions module + * and populate the state within the ToolboxController component. + */ + async _buildInitialPanelDefinitions() { + // Get the initial list of tab definitions. This list can be amended at a later time + // by tools registering themselves. + const definitions = gDevTools.getToolDefinitionArray(); + definitions.forEach(definition => this._buildPanelForTool(definition)); + + // Get the definitions that will only affect the main tab area. + this.panelDefinitions = definitions.filter( + definition => + definition.isToolSupported(this) && definition.id !== "options" + ); + }, + + async _setInitialMeatballState() { + let disableAutohide, pseudoLocale; + // Popup auto-hide disabling is only available in browser toolbox and webextension toolboxes. + if ( + this.isBrowserToolbox || + this._descriptorFront.isWebExtensionDescriptor + ) { + disableAutohide = await this._isDisableAutohideEnabled(); + } + // Pseudo locale items are only displayed in the browser toolbox + if (this.isBrowserToolbox) { + pseudoLocale = await this.getPseudoLocale(); + } + // Parallelize the asynchronous calls, so that the DOM is only updated once when + // updating the React components. + if (typeof disableAutohide == "boolean") { + this.component.setDisableAutohide(disableAutohide); + } + if (typeof pseudoLocale == "string") { + this.component.setPseudoLocale(pseudoLocale); + } + if ( + this._descriptorFront.isWebExtensionDescriptor && + this.hostType === Toolbox.HostType.WINDOW + ) { + const alwaysOnTop = Services.prefs.getBoolPref( + DEVTOOLS_ALWAYS_ON_TOP, + false + ); + this.component.setAlwaysOnTop(alwaysOnTop); + } + }, + + /** + * Initiate ToolboxController React component and all it's properties. Do the initial render. + * + * @param {Object} fluentBundles + * A FluentBundle instance used to display any localized text in the React component. + */ + _mountReactComponent(fluentBundles) { + // Ensure the toolbar doesn't try to render until the tool is ready. + const element = this.React.createElement(this.ToolboxController, { + L10N, + fluentBundles, + currentToolId: this.currentToolId, + selectTool: this.selectTool, + toggleOptions: this.toggleOptions, + toggleSplitConsole: this.toggleSplitConsole, + toggleNoAutohide: this.toggleNoAutohide, + toggleAlwaysOnTop: this.toggleAlwaysOnTop, + disablePseudoLocale: this.disablePseudoLocale, + enableAccentedPseudoLocale: this.enableAccentedPseudoLocale, + enableBidiPseudoLocale: this.enableBidiPseudoLocale, + closeToolbox: this.closeToolbox, + focusButton: this._onToolbarFocus, + toolbox: this, + onTabsOrderUpdated: this._onTabsOrderUpdated, + }); + + this.component = this.ReactDOM.render(element, this._componentMount); + }, + + /** + * Reset tabindex attributes across all focusable elements inside the toolbar. + * Only have one element with tabindex=0 at a time to make sure that tabbing + * results in navigating away from the toolbar container. + * @param {FocusEvent} event + */ + _onToolbarFocus(id) { + this.component.setFocusedButton(id); + }, + + /** + * On left/right arrow press, attempt to move the focus inside the toolbar to + * the previous/next focusable element. This is not in the React component + * as it is difficult to coordinate between different component elements. + * The components are responsible for setting the correct tabindex value + * for if they are the focused element. + * @param {KeyboardEvent} event + */ + _onToolbarArrowKeypress(event) { + const { key, target, ctrlKey, shiftKey, altKey, metaKey } = event; + + // If any of the modifier keys are pressed do not attempt navigation as it + // might conflict with global shortcuts (Bug 1327972). + if (ctrlKey || shiftKey || altKey || metaKey) { + return; + } + + const buttons = [...this._tabBar.querySelectorAll("button")]; + const curIndex = buttons.indexOf(target); + + if (curIndex === -1) { + console.warn( + target + + " is not found among Developer Tools tab bar " + + "focusable elements." + ); + return; + } + + let newTarget; + const firstTabIndex = 0; + const lastTabIndex = buttons.length - 1; + const nextOrLastTabIndex = Math.min(lastTabIndex, curIndex + 1); + const previousOrFirstTabIndex = Math.max(firstTabIndex, curIndex - 1); + const ltr = this.direction === "ltr"; + + if (key === "ArrowLeft") { + // Do nothing if already at the beginning. + if ( + (ltr && curIndex === firstTabIndex) || + (!ltr && curIndex === lastTabIndex) + ) { + return; + } + newTarget = buttons[ltr ? previousOrFirstTabIndex : nextOrLastTabIndex]; + } else if (key === "ArrowRight") { + // Do nothing if already at the end. + if ( + (ltr && curIndex === lastTabIndex) || + (!ltr && curIndex === firstTabIndex) + ) { + return; + } + newTarget = buttons[ltr ? nextOrLastTabIndex : previousOrFirstTabIndex]; + } else { + return; + } + + newTarget.focus(); + + event.preventDefault(); + event.stopPropagation(); + }, + + /** + * Add buttons to the UI as specified in devtools/client/definitions.js + */ + _buildButtons() { + // Beyond the normal preference filtering + this.toolbarButtons = [ + this._buildErrorCountButton(), + this._buildPickerButton(), + this._buildFrameButton(), + ]; + + ToolboxButtons.forEach(definition => { + const button = this._createButtonState(definition); + this.toolbarButtons.push(button); + }); + + this.component.setToolboxButtons(this.toolbarButtons); + }, + + /** + * Button to select a frame for the inspector to target. + */ + _buildFrameButton() { + this.frameButton = this._createButtonState({ + id: "command-button-frames", + description: L10N.getStr("toolbox.frames.tooltip"), + isToolSupported: toolbox => { + return toolbox.target.getTrait("frames"); + }, + isCurrentlyVisible: () => { + const hasFrames = this.frameMap.size > 1; + const isOnOptionsPanel = this.currentToolId === "options"; + return hasFrames || isOnOptionsPanel; + }, + }); + + return this.frameButton; + }, + + /** + * Button to display the number of errors. + */ + _buildErrorCountButton() { + this.errorCountButton = this._createButtonState({ + id: "command-button-errorcount", + isInStartContainer: false, + isToolSupported: toolbox => true, + description: L10N.getStr("toolbox.errorCountButton.description"), + }); + // Use updateErrorCountButton to set some properties so we don't have to repeat + // the logic here. + this.updateErrorCountButton(); + + return this.errorCountButton; + }, + + /** + * Toggle the picker, but also decide whether or not the highlighter should + * focus the window. This is only desirable when the toolbox is mounted to the + * window. When devtools is free floating, then the target window should not + * pop in front of the viewer when the picker is clicked. + * + * Note: Toggle picker can be overwritten by panel other than the inspector to + * allow for custom picker behaviour. + */ + async _onPickerClick() { + const focus = + this.hostType === Toolbox.HostType.BOTTOM || + this.hostType === Toolbox.HostType.LEFT || + this.hostType === Toolbox.HostType.RIGHT; + const currentPanel = this.getCurrentPanel(); + if (currentPanel.togglePicker) { + currentPanel.togglePicker(focus); + } else { + this.nodePicker.togglePicker(focus); + } + }, + + /** + * If the picker is activated, then allow the Escape key to deactivate the + * functionality instead of the default behavior of toggling the console. + */ + _onPickerKeypress(event) { + if (event.keyCode === KeyCodes.DOM_VK_ESCAPE) { + const currentPanel = this.getCurrentPanel(); + if (currentPanel.cancelPicker) { + currentPanel.cancelPicker(); + } else { + this.nodePicker.stop({ canceled: true }); + } + // Stop the console from toggling. + event.stopImmediatePropagation(); + } + }, + + async _onPickerStarting() { + if (this.isDestroying()) { + return; + } + this.tellRDMAboutPickerState(true, PICKER_TYPES.ELEMENT); + this.pickerButton.isChecked = true; + await this.selectTool("inspector", "inspect_dom"); + // turn off color picker when node picker is starting + this.getPanel("inspector").hideEyeDropper(); + this.on("select", this._onToolSelectedStopPicker); + }, + + async _onPickerStarted() { + this.doc.addEventListener("keypress", this._onPickerKeypress, true); + }, + + _onPickerStopped() { + if (this.isDestroying()) { + return; + } + this.tellRDMAboutPickerState(false, PICKER_TYPES.ELEMENT); + this.off("select", this._onToolSelectedStopPicker); + this.doc.removeEventListener("keypress", this._onPickerKeypress, true); + this.pickerButton.isChecked = false; + }, + + _onToolSelectedStopPicker() { + this.nodePicker.stop({ canceled: true }); + }, + + /** + * When the picker is canceled, make sure the toolbox + * gets the focus. + */ + _onPickerCanceled() { + if (this.hostType !== Toolbox.HostType.WINDOW) { + this.win.focus(); + } + }, + + _onPickerPicked(nodeFront) { + this.selection.setNodeFront(nodeFront, { reason: "picker-node-picked" }); + }, + + _onPickerPreviewed(nodeFront) { + this.selection.setNodeFront(nodeFront, { reason: "picker-node-previewed" }); + }, + + /** + * RDM sometimes simulates touch events. For this to work correctly at all times, it + * needs to know when the picker is active or not. + * This method communicates with the RDM Manager if it exists. + * + * @param {Boolean} state + * @param {String} pickerType + * One of devtools/shared/picker-constants + */ + async tellRDMAboutPickerState(state, pickerType) { + const { localTab } = this.target; + + if (!ResponsiveUIManager.isActiveForTab(localTab)) { + return; + } + + const ui = ResponsiveUIManager.getResponsiveUIForTab(localTab); + await ui.responsiveFront.setElementPickerState(state, pickerType); + }, + + /** + * The element picker button enables the ability to select a DOM node by clicking + * it on the page. + */ + _buildPickerButton() { + this.pickerButton = this._createButtonState({ + id: "command-button-pick", + className: this._getPickerAdditionalClassName(), + description: this._getPickerTooltip(), + onClick: this._onPickerClick, + isInStartContainer: true, + isToolSupported: toolbox => { + return toolbox.target.getTrait("frames"); + }, + isToggle: true, + }); + + return this.pickerButton; + }, + + _getPickerAdditionalClassName() { + if (this.isDebugTargetFenix()) { + return "remote-fenix"; + } + return null; + }, + + /** + * Get the tooltip for the element picker button. + * It has multiple possible keyboard shortcuts for macOS. + * + * @return {String} + */ + _getPickerTooltip() { + let shortcut = L10N.getStr("toolbox.elementPicker.key"); + shortcut = KeyShortcuts.parseElectronKey(this.win, shortcut); + shortcut = KeyShortcuts.stringify(shortcut); + const shortcutMac = L10N.getStr("toolbox.elementPicker.mac.key"); + const isMac = Services.appinfo.OS === "Darwin"; + + let label; + if (this.isDebugTargetFenix()) { + label = isMac + ? "toolbox.androidElementPicker.mac.tooltip" + : "toolbox.androidElementPicker.tooltip"; + } else { + label = isMac + ? "toolbox.elementPicker.mac.tooltip" + : "toolbox.elementPicker.tooltip"; + } + + return isMac + ? L10N.getFormatStr(label, shortcut, shortcutMac) + : L10N.getFormatStr(label, shortcut); + }, + + /** + * Apply the current cache setting from devtools.cache.disabled to this + * toolbox's tab. + */ + async _applyCacheSettings() { + const pref = "devtools.cache.disabled"; + const cacheDisabled = Services.prefs.getBoolPref(pref); + + await this.commands.targetConfigurationCommand.updateConfiguration({ + cacheDisabled, + }); + + // This event is only emitted for tests in order to know when to reload + if (flags.testing) { + this.emit("cache-reconfigured"); + } + }, + + /** + * Apply the custom formatter setting (from `devtools.custom-formatters.enabled`) to this + * toolbox's tab. + */ + async _applyCustomFormatterSetting() { + if (!this.commands) { + return; + } + + const customFormatters = Services.prefs.getBoolPref( + "devtools.custom-formatters.enabled", + false + ); + + await this.commands.targetConfigurationCommand.updateConfiguration({ + customFormatters, + }); + + this.emitForTests("custom-formatters-reconfigured"); + }, + + /** + * Apply the current service workers testing setting from + * devtools.serviceWorkers.testing.enabled to this toolbox's tab. + */ + _applyServiceWorkersTestingSettings() { + const pref = "devtools.serviceWorkers.testing.enabled"; + const serviceWorkersTestingEnabled = Services.prefs.getBoolPref(pref); + this.commands.targetConfigurationCommand.updateConfiguration({ + serviceWorkersTestingEnabled, + }); + }, + + /** + * Apply the current simple highlighters setting to this toolbox's tab. + */ + _applySimpleHighlightersSettings() { + const useSimpleHighlightersForReducedMotion = Services.prefs.getBoolPref( + "devtools.inspector.simple-highlighters-reduced-motion", + false + ); + this.commands.targetConfigurationCommand.updateConfiguration({ + useSimpleHighlightersForReducedMotion, + }); + }, + + /** + * Update the visibility of the buttons. + */ + updateToolboxButtonsVisibility() { + this.toolbarButtons.forEach(button => { + button.isVisible = this._commandIsVisible(button); + }); + this.component.setToolboxButtons(this.toolbarButtons); + }, + + /** + * Update the buttons. + */ + updateToolboxButtons() { + const inspectorFront = this.target.getCachedFront("inspector"); + // two of the buttons have highlighters that need to be cleared + // on will-navigate, otherwise we hold on to the stale highlighter + const hasHighlighters = + inspectorFront && + (inspectorFront.hasHighlighter("RulersHighlighter") || + inspectorFront.hasHighlighter("MeasuringToolHighlighter")); + if (hasHighlighters) { + inspectorFront.destroyHighlighters(); + this.component.setToolboxButtons(this.toolbarButtons); + } + }, + + /** + * Visually update picker button. + * This function is called on every "select" event. Newly selected panel can + * update the visual state of the picker button such as disabled state, + * additional CSS classes (className), and tooltip (description). + */ + updatePickerButton() { + const button = this.pickerButton; + const currentPanel = this.getCurrentPanel(); + + if (currentPanel?.updatePickerButton) { + currentPanel.updatePickerButton(); + } else { + // If the current panel doesn't define a custom updatePickerButton, + // revert the button to its default state + button.description = this._getPickerTooltip(); + button.className = this._getPickerAdditionalClassName(); + button.disabled = null; + } + }, + + /** + * Update the visual state of the Frame picker button. + */ + updateFrameButton() { + if (this.isDestroying()) { + return; + } + + if (this.currentToolId === "options" && this.frameMap.size <= 1) { + // If the button is only visible because the user is on the Options panel, disable + // the button and set an appropriate description. + this.frameButton.disabled = true; + this.frameButton.description = L10N.getStr( + "toolbox.frames.disabled.tooltip" + ); + } else { + // Otherwise, enable the button and update the description. + this.frameButton.disabled = false; + this.frameButton.description = L10N.getStr("toolbox.frames.tooltip"); + } + + // Highlight the button when a child frame is selected and visible. + const selectedFrame = this.frameMap.get(this.selectedFrameId) || {}; + + // We need to do something a bit different to avoid some test failures. This function + // can be called from onWillNavigate, and the current target might have this `traits` + // property nullifed, which is unfortunate as that's what isToolSupported is checking, + // so it will throw. + // So here, we check first if the button isn't going to be visible anyway (it only checks + // for this.frameMap size) so we don't call _commandIsVisible. + const isVisible = !this.frameButton.isCurrentlyVisible() + ? false + : this._commandIsVisible(this.frameButton); + + this.frameButton.isVisible = isVisible; + + if (isVisible) { + this.frameButton.isChecked = !selectedFrame.isTopLevel; + } + }, + + updateErrorCountButton() { + this.errorCountButton.isVisible = + this._commandIsVisible(this.errorCountButton) && this._errorCount > 0; + this.errorCountButton.errorCount = this._errorCount; + }, + + /** + * Ensure the visibility of each toolbox button matches the preference value. + */ + _commandIsVisible(button) { + const { isToolSupported, isCurrentlyVisible, visibilityswitch } = button; + + if (!Services.prefs.getBoolPref(visibilityswitch, true)) { + return false; + } + + if (isToolSupported && !isToolSupported(this)) { + return false; + } + + if (isCurrentlyVisible && !isCurrentlyVisible()) { + return false; + } + + return true; + }, + + /** + * Build a panel for a tool definition. + * + * @param {string} toolDefinition + * Tool definition of the tool to build a tab for. + */ + _buildPanelForTool(toolDefinition) { + if (!toolDefinition.isToolSupported(this)) { + return; + } + + const deck = this.doc.getElementById("toolbox-deck"); + const id = toolDefinition.id; + + if (toolDefinition.ordinal == undefined || toolDefinition.ordinal < 0) { + toolDefinition.ordinal = MAX_ORDINAL; + } + + if (!toolDefinition.bgTheme) { + toolDefinition.bgTheme = "theme-toolbar"; + } + const panel = this.doc.createXULElement("vbox"); + panel.className = "toolbox-panel " + toolDefinition.bgTheme; + + // There is already a container for the webconsole frame. + if (!this.doc.getElementById("toolbox-panel-" + id)) { + panel.id = "toolbox-panel-" + id; + } + + deck.appendChild(panel); + }, + + /** + * Lazily created map of the additional tools registered to this toolbox. + * + * @returns {Map<string, object>} + * a map of the tools definitions registered to this + * particular toolbox (the key is the toolId string, the value + * is the tool definition plain javascript object). + */ + get additionalToolDefinitions() { + if (!this._additionalToolDefinitions) { + this._additionalToolDefinitions = new Map(); + } + + return this._additionalToolDefinitions; + }, + + /** + * Retrieve the array of the additional tools registered to this toolbox. + * + * @return {Array<object>} + * the array of additional tool definitions registered on this toolbox. + */ + getAdditionalTools() { + if (this._additionalToolDefinitions) { + return Array.from(this.additionalToolDefinitions.values()); + } + return []; + }, + + /** + * Get the additional tools that have been registered and are visible. + * + * @return {Array<object>} + * the array of additional tool definitions registered on this toolbox. + */ + getVisibleAdditionalTools() { + return this.visibleAdditionalTools.map(toolId => + this.additionalToolDefinitions.get(toolId) + ); + }, + + /** + * Test the existence of a additional tools registered to this toolbox by tool id. + * + * @param {string} toolId + * the id of the tool to test for existence. + * + * @return {boolean} + * + */ + hasAdditionalTool(toolId) { + return this.additionalToolDefinitions.has(toolId); + }, + + /** + * Register and load an additional tool on this particular toolbox. + * + * @param {object} definition + * the additional tool definition to register and add to this toolbox. + */ + addAdditionalTool(definition) { + if (!definition.id) { + throw new Error("Tool definition id is missing"); + } + + if (this.isToolRegistered(definition.id)) { + throw new Error("Tool definition already registered: " + definition.id); + } + + this.additionalToolDefinitions.set(definition.id, definition); + this.visibleAdditionalTools = [ + ...this.visibleAdditionalTools, + definition.id, + ]; + + const buildPanel = () => this._buildPanelForTool(definition); + + if (this.isReady) { + buildPanel(); + } else { + this.once("ready", buildPanel); + } + }, + + /** + * Retrieve the registered inspector extension sidebars + * (used by the inspector panel during its deferred initialization). + */ + get inspectorExtensionSidebars() { + return this._inspectorExtensionSidebars; + }, + + /** + * Register an extension sidebar for the inspector panel. + * + * @param {String} id + * An unique sidebar id + * @param {Object} options + * @param {String} options.title + * A title for the sidebar + */ + async registerInspectorExtensionSidebar(id, options) { + this._inspectorExtensionSidebars.set(id, options); + + // Defer the extension sidebar creation if the inspector + // has not been created yet (and do not create the inspector + // only to register an extension sidebar). + if (!this.target.getCachedFront("inspector")) { + return; + } + + const inspector = this.getPanel("inspector"); + if (!inspector) { + return; + } + + inspector.addExtensionSidebar(id, options); + }, + + /** + * Unregister an extension sidebar for the inspector panel. + * + * @param {String} id + * An unique sidebar id + */ + unregisterInspectorExtensionSidebar(id) { + // Unregister the sidebar from the toolbox if the toolbox is not already + // being destroyed (otherwise we would trigger a re-rendering of the + // inspector sidebar tabs while the toolbox is going away). + if (this._destroyer) { + return; + } + + const sidebarDef = this._inspectorExtensionSidebars.get(id); + if (!sidebarDef) { + return; + } + + this._inspectorExtensionSidebars.delete(id); + + // Remove the created sidebar instance if the inspector panel + // has been already created. + if (!this.target.getCachedFront("inspector")) { + return; + } + + const inspector = this.getPanel("inspector"); + inspector.removeExtensionSidebar(id); + }, + + /** + * Unregister and unload an additional tool from this particular toolbox. + * + * @param {string} toolId + * the id of the additional tool to unregister and remove. + */ + removeAdditionalTool(toolId) { + // Early exit if the toolbox is already destroying itself. + if (this._destroyer) { + return; + } + + if (!this.hasAdditionalTool(toolId)) { + throw new Error( + "Tool definition not registered to this toolbox: " + toolId + ); + } + + this.additionalToolDefinitions.delete(toolId); + this.visibleAdditionalTools = this.visibleAdditionalTools.filter( + id => id !== toolId + ); + this.unloadTool(toolId); + }, + + /** + * Ensure the tool with the given id is loaded. + * + * @param {string} id + * The id of the tool to load. + * @param {Object} options + * Object that will be passed to the panel `open` method. + */ + loadTool(id, options) { + let iframe = this.doc.getElementById("toolbox-panel-iframe-" + id); + if (iframe) { + const panel = this._toolPanels.get(id); + return new Promise(resolve => { + if (panel) { + resolve(panel); + } else { + this.once(id + "-ready", initializedPanel => { + resolve(initializedPanel); + }); + } + }); + } + + return new Promise((resolve, reject) => { + // Retrieve the tool definition (from the global or the per-toolbox tool maps) + const definition = this.getToolDefinition(id); + + if (!definition) { + reject(new Error("no such tool id " + id)); + return; + } + + iframe = this.doc.createXULElement("iframe"); + iframe.className = "toolbox-panel-iframe"; + iframe.id = "toolbox-panel-iframe-" + id; + iframe.setAttribute("flex", 1); + iframe.setAttribute("forceOwnRefreshDriver", ""); + iframe.tooltip = "aHTMLTooltip"; + iframe.style.visibility = "hidden"; + + gDevTools.emit(id + "-init", this, iframe); + this.emit(id + "-init", iframe); + + // If no parent yet, append the frame into default location. + if (!iframe.parentNode) { + const vbox = this.doc.getElementById("toolbox-panel-" + id); + vbox.appendChild(iframe); + vbox.visibility = "visible"; + } + + const onLoad = async () => { + // Prevent flicker while loading by waiting to make visible until now. + iframe.style.visibility = "visible"; + + // Try to set the dir attribute as early as possible. + this.setIframeDocumentDir(iframe); + + // The build method should return a panel instance, so events can + // be fired with the panel as an argument. However, in order to keep + // backward compatibility with existing extensions do a check + // for a promise return value. + let built = definition.build(iframe.contentWindow, this, this.commands); + + if (!(typeof built.then == "function")) { + const panel = built; + iframe.panel = panel; + + // The panel instance is expected to fire (and listen to) various + // framework events, so make sure it's properly decorated with + // appropriate API (on, off, once, emit). + // In this case we decorate panel instances directly returned by + // the tool definition 'build' method. + if (typeof panel.emit == "undefined") { + EventEmitter.decorate(panel); + } + + gDevTools.emit(id + "-build", this, panel); + this.emit(id + "-build", panel); + + // The panel can implement an 'open' method for asynchronous + // initialization sequence. + if (typeof panel.open == "function") { + built = panel.open(options); + } else { + built = new Promise(resolve => { + resolve(panel); + }); + } + } + + // Wait till the panel is fully ready and fire 'ready' events. + Promise.resolve(built).then(panel => { + this._toolPanels.set(id, panel); + + // Make sure to decorate panel object with event API also in case + // where the tool definition 'build' method returns only a promise + // and the actual panel instance is available as soon as the + // promise is resolved. + if (typeof panel.emit == "undefined") { + EventEmitter.decorate(panel); + } + + gDevTools.emit(id + "-ready", this, panel); + this.emit(id + "-ready", panel); + + resolve(panel); + }, console.error); + }; + + iframe.setAttribute("src", definition.url); + if (definition.panelLabel) { + iframe.setAttribute("aria-label", definition.panelLabel); + } + + // Depending on the host, iframe.contentWindow is not always + // defined at this moment. If it is not defined, we use an + // event listener on the iframe DOM node. If it's defined, + // we use the chromeEventHandler. We can't use a listener + // on the DOM node every time because this won't work + // if the (xul chrome) iframe is loaded in a content docshell. + if (iframe.contentWindow) { + DOMHelpers.onceDOMReady(iframe.contentWindow, onLoad); + } else { + const callback = () => { + iframe.removeEventListener("DOMContentLoaded", callback); + onLoad(); + }; + + iframe.addEventListener("DOMContentLoaded", callback); + } + }); + }, + + /** + * Set the dir attribute on the content document element of the provided iframe. + * + * @param {IFrameElement} iframe + */ + setIframeDocumentDir(iframe) { + const docEl = iframe.contentWindow?.document.documentElement; + if (!docEl || docEl.namespaceURI !== HTML_NS) { + // Bail out if the content window or document is not ready or if the document is not + // HTML. + return; + } + + if (docEl.hasAttribute("dir")) { + // Set the dir attribute value only if dir is already present on the document. + docEl.setAttribute("dir", this.direction); + } + }, + + /** + * Mark all in collection as unselected; and id as selected + * @param {string} collection + * DOM collection of items + * @param {string} id + * The Id of the item within the collection to select + */ + selectSingleNode(collection, id) { + [...collection].forEach(node => { + if (node.id === id) { + node.setAttribute("selected", "true"); + node.setAttribute("aria-selected", "true"); + } else { + node.removeAttribute("selected"); + node.removeAttribute("aria-selected"); + } + // The webconsole panel is in a special location due to split console + if (!node.id) { + node = this.webconsolePanel; + } + + const iframe = node.querySelector(".toolbox-panel-iframe"); + if (iframe) { + let visible = node.id == id; + // Prevents hiding the split-console if it is currently enabled + if (node == this.webconsolePanel && this.splitConsole) { + visible = true; + } + this.setIframeVisible(iframe, visible); + } + }); + }, + + /** + * Make a privileged iframe visible/hidden. + * + * For now, XUL Iframes loading chrome documents (i.e. <iframe type!="content" />) + * can't be hidden at platform level. And so don't support 'visibilitychange' event. + * + * This helper workarounds that by at least being able to send these kind of events. + * It will help panel react differently depending on them being displayed or in + * background. + */ + setIframeVisible(iframe, visible) { + const state = visible ? "visible" : "hidden"; + const win = iframe.contentWindow; + const doc = win.document; + if (doc.visibilityState != state) { + // 1) Overload document's `visibilityState` attribute + // Use defineProperty, as by default `document.visbilityState` is read only. + Object.defineProperty(doc, "visibilityState", { + value: state, + configurable: true, + }); + + // 2) Fake the 'visibilitychange' event + doc.dispatchEvent(new win.Event("visibilitychange")); + } + }, + + /** + * Switch to the tool with the given id + * + * @param {string} id + * The id of the tool to switch to + * @param {string} reason + * Reason the tool was opened + * @param {Object} options + * Object that will be passed to the panel + */ + selectTool(id, reason = "unknown", options) { + this.emit("panel-changed"); + + if (this.currentToolId == id) { + const panel = this._toolPanels.get(id); + if (panel) { + // We have a panel instance, so the tool is already fully loaded. + + // re-focus tool to get key events again + this.focusTool(id); + + // Return the existing panel in order to have a consistent return value. + return Promise.resolve(panel); + } + // Otherwise, if there is no panel instance, it is still loading, + // so we are racing another call to selectTool with the same id. + return this.once("select").then(() => + Promise.resolve(this._toolPanels.get(id)) + ); + } + + if (!this.isReady) { + throw new Error("Can't select tool, wait for toolbox 'ready' event"); + } + + // Check if the tool exists. + if ( + this.panelDefinitions.find(definition => definition.id === id) || + id === "options" || + this.additionalToolDefinitions.get(id) + ) { + if (this.currentToolId) { + this.telemetry.toolClosed(this.currentToolId, this); + } + + this._pingTelemetrySelectTool(id, reason); + } else { + throw new Error("No tool found"); + } + + // and select the right iframe + const toolboxPanels = this.doc.querySelectorAll(".toolbox-panel"); + this.selectSingleNode(toolboxPanels, "toolbox-panel-" + id); + + this.lastUsedToolId = this.currentToolId; + this.currentToolId = id; + this._refreshConsoleDisplay(); + if (id != "options") { + Services.prefs.setCharPref(this._prefs.LAST_TOOL, id); + } + + return this.loadTool(id, options).then(panel => { + // focus the tool's frame to start receiving key events + this.focusTool(id); + + this.emit("select", id); + this.emit(id + "-selected", panel); + return panel; + }); + }, + + _pingTelemetrySelectTool(id, reason) { + const width = Math.ceil(this.win.outerWidth / 50) * 50; + const panelName = this.getTelemetryPanelNameOrOther(id); + const prevPanelName = this.getTelemetryPanelNameOrOther(this.currentToolId); + const cold = !this.getPanel(id); + const pending = ["host", "width", "start_state", "panel_name", "cold"]; + + // On first load this.currentToolId === undefined so we need to skip sending + // a devtools.main.exit telemetry event. + if (this.currentToolId) { + this.telemetry.recordEvent("exit", prevPanelName, null, { + host: this._hostType, + width, + panel_name: prevPanelName, + next_panel: panelName, + reason, + }); + } + + this.telemetry.addEventProperties(this.topWindow, "open", "tools", null, { + width, + }); + + if (id === "webconsole") { + pending.push("message_count"); + } + + this.telemetry.preparePendingEvent(this, "enter", panelName, null, pending); + + this.telemetry.addEventProperties(this, "enter", panelName, null, { + host: this._hostType, + start_state: reason, + panel_name: panelName, + cold, + }); + + if (reason !== "initial_panel") { + const width = Math.ceil(this.win.outerWidth / 50) * 50; + this.telemetry.addEventProperty( + this, + "enter", + panelName, + null, + "width", + width + ); + } + + // Cold webconsole event message_count is handled in + // devtools/client/webconsole/webconsole-wrapper.js + if (!cold && id === "webconsole") { + this.telemetry.addEventProperty( + this, + "enter", + "webconsole", + null, + "message_count", + 0 + ); + } + + this.telemetry.toolOpened(id, this); + }, + + /** + * Focus a tool's panel by id + * @param {string} id + * The id of tool to focus + */ + focusTool(id, state = true) { + const iframe = this.doc.getElementById("toolbox-panel-iframe-" + id); + + if (state) { + iframe.focus(); + } else { + iframe.blur(); + } + }, + + /** + * Focus split console's input line + */ + focusConsoleInput() { + const consolePanel = this.getPanel("webconsole"); + if (consolePanel) { + consolePanel.focusInput(); + } + }, + + /** + * Disable all network logs in the console + */ + disableAllConsoleNetworkLogs() { + const consolePanel = this.getPanel("webconsole"); + if (consolePanel) { + consolePanel.hud.ui.disableAllNetworkMessages(); + } + }, + + /** + * If the console is split and we are focusing an element outside + * of the console, then store the newly focused element, so that + * it can be restored once the split console closes. + * + * @param Element originalTarget + * The DOM Element that just got focused. + */ + _updateLastFocusedElementForSplitConsole(originalTarget) { + // Ignore any non element nodes, or any elements contained + // within the webconsole frame. + const webconsoleURL = gDevTools.getToolDefinition("webconsole").url; + if ( + originalTarget.nodeType !== 1 || + originalTarget.baseURI === webconsoleURL + ) { + return; + } + + this._lastFocusedElement = originalTarget; + }, + + // Report if the toolbox is currently focused, + // or the focus in elsewhere in the browser or another app. + _isToolboxFocused: false, + + _onFocus({ originalTarget }) { + this._isToolboxFocused = true; + this._debounceUpdateFocusedState(); + + this._updateLastFocusedElementForSplitConsole(originalTarget); + }, + + _onBlur() { + this._isToolboxFocused = false; + this._debounceUpdateFocusedState(); + }, + + _onTabsOrderUpdated() { + this._combineAndSortPanelDefinitions(); + }, + + /** + * Opens the split console. + * + * @param {boolean} focusConsoleInput + * By default, the console input will be focused. + * Pass false in order to prevent this. + * + * @returns {Promise} a promise that resolves once the tool has been + * loaded and focused. + */ + openSplitConsole({ focusConsoleInput = true } = {}) { + this._splitConsole = true; + Services.prefs.setBoolPref(SPLITCONSOLE_ENABLED_PREF, true); + this._refreshConsoleDisplay(); + + // Ensure split console is visible if console was already loaded in background + const iframe = this.webconsolePanel.querySelector(".toolbox-panel-iframe"); + if (iframe) { + this.setIframeVisible(iframe, true); + } + + return this.loadTool("webconsole").then(() => { + this.component.setIsSplitConsoleActive(true); + this.telemetry.recordEvent("activate", "split_console", null, { + host: this._getTelemetryHostString(), + width: Math.ceil(this.win.outerWidth / 50) * 50, + }); + this.emit("split-console"); + if (focusConsoleInput) { + this.focusConsoleInput(); + } + }); + }, + + /** + * Closes the split console. + * + * @returns {Promise} a promise that resolves once the tool has been + * closed. + */ + closeSplitConsole() { + this._splitConsole = false; + Services.prefs.setBoolPref(SPLITCONSOLE_ENABLED_PREF, false); + this._refreshConsoleDisplay(); + this.component.setIsSplitConsoleActive(false); + + this.telemetry.recordEvent("deactivate", "split_console", null, { + host: this._getTelemetryHostString(), + width: Math.ceil(this.win.outerWidth / 50) * 50, + }); + + this.emit("split-console"); + + if (this._lastFocusedElement) { + this._lastFocusedElement.focus(); + } + return Promise.resolve(); + }, + + /** + * Toggles the split state of the webconsole. If the webconsole panel + * is already selected then this command is ignored. + * + * @returns {Promise} a promise that resolves once the tool has been + * opened or closed. + */ + toggleSplitConsole() { + if (this.currentToolId !== "webconsole") { + return this.splitConsole + ? this.closeSplitConsole() + : this.openSplitConsole(); + } + + return Promise.resolve(); + }, + + /** + * Toggles the options panel. + * If the option panel is already selected then select the last selected panel. + */ + toggleOptions(event) { + // Flip back to the last used panel if we are already + // on the options panel. + if ( + this.currentToolId === "options" && + gDevTools.getToolDefinition(this.lastUsedToolId) + ) { + this.selectTool(this.lastUsedToolId, "toggle_settings_off"); + } else { + this.selectTool("options", "toggle_settings_on"); + } + + // preventDefault will avoid a Linux only bug when the focus is on a text input + // See Bug 1519087. + event.preventDefault(); + }, + + /** + * Loads the tool next to the currently selected tool. + */ + selectNextTool() { + const definitions = this.component.panelDefinitions; + const index = definitions.findIndex(({ id }) => id === this.currentToolId); + const definition = + index === -1 || index >= definitions.length - 1 + ? definitions[0] + : definitions[index + 1]; + return this.selectTool(definition.id, "select_next_key"); + }, + + /** + * Loads the tool just left to the currently selected tool. + */ + selectPreviousTool() { + const definitions = this.component.panelDefinitions; + const index = definitions.findIndex(({ id }) => id === this.currentToolId); + const definition = + index === -1 || index < 1 + ? definitions[definitions.length - 1] + : definitions[index - 1]; + return this.selectTool(definition.id, "select_prev_key"); + }, + + /** + * Tells if the given tool is currently highlighted. + * (doesn't mean selected, its tab header will be green) + * + * @param {string} id + * The id of the tool to check. + */ + isHighlighted(id) { + return this.component.state.highlightedTools.has(id); + }, + + /** + * Highlights the tool's tab if it is not the currently selected tool. + * + * @param {string} id + * The id of the tool to highlight + */ + async highlightTool(id) { + if (!this.component) { + await this.isOpen; + } + this.component.highlightTool(id); + }, + + /** + * De-highlights the tool's tab. + * + * @param {string} id + * The id of the tool to unhighlight + */ + async unhighlightTool(id) { + if (!this.component) { + await this.isOpen; + } + this.component.unhighlightTool(id); + }, + + /** + * Raise the toolbox host. + */ + raise() { + this.postMessage({ name: "raise-host" }); + + return this.once("host-raised"); + }, + + /** + * Fired when user just started navigating away to another web page. + */ + async _onWillNavigate({ isFrameSwitching } = {}) { + // On navigate, the server will resume all paused threads, but due to an + // issue which can cause loosing outgoing messages/RDP packets, the THREAD_STATE + // resources for the resumed state might not get received. So let assume it happens + // make use the UI is the appropriate state. + if (this._pausedTargets > 0) { + this.emit("toolbox-resumed"); + this._pausedTargets = 0; + if (this.isHighlighted("jsdebugger")) { + this.unhighlightTool("jsdebugger"); + } + } + + // Clearing the error count and the iframe list as soon as we navigate + this.setErrorCount(0); + if (!isFrameSwitching) { + this._updateFrames({ destroyAll: true }); + } + this.updateToolboxButtons(); + const toolId = this.currentToolId; + // For now, only inspector, webconsole, netmonitor and accessibility fire "reloaded" event + if ( + toolId != "inspector" && + toolId != "webconsole" && + toolId != "netmonitor" && + toolId != "accessibility" + ) { + return; + } + + const start = this.win.performance.now(); + const panel = this.getPanel(toolId); + // Ignore the timing if the panel is still loading + if (!panel) { + return; + } + + await panel.once("reloaded"); + // The toolbox may have been destroyed while the panel was reloading + if (this.isDestroying()) { + return; + } + const delay = this.win.performance.now() - start; + + const telemetryKey = "DEVTOOLS_TOOLBOX_PAGE_RELOAD_DELAY_MS"; + this.telemetry.getKeyedHistogramById(telemetryKey).add(toolId, delay); + }, + + /** + * Refresh the host's title. + */ + _refreshHostTitle() { + let title; + + if (this.target.isXpcShellTarget) { + // This will only be displayed for local development and can remain + // hardcoded in english. + title = "XPCShell Toolbox"; + } else if (this.isMultiProcessBrowserToolbox) { + const scope = Services.prefs.getCharPref(BROWSERTOOLBOX_SCOPE_PREF); + if (scope == BROWSERTOOLBOX_SCOPE_EVERYTHING) { + title = L10N.getStr("toolbox.multiProcessBrowserToolboxTitle"); + } else if (scope == BROWSERTOOLBOX_SCOPE_PARENTPROCESS) { + title = L10N.getStr("toolbox.parentProcessBrowserToolboxTitle"); + } else { + throw new Error("Unsupported scope: " + scope); + } + } else if (this.target.name && this.target.name != this.target.url) { + const url = this.target.isWebExtension + ? this.target.getExtensionPathName(this.target.url) + : getUnicodeUrl(this.target.url); + title = L10N.getFormatStr( + "toolbox.titleTemplate2", + this.target.name, + url + ); + } else { + title = L10N.getFormatStr( + "toolbox.titleTemplate1", + getUnicodeUrl(this.target.url) + ); + } + this.postMessage({ + name: "set-host-title", + title, + }); + }, + + /** + * Returns an instance of the preference actor. This is a lazily initialized root + * actor that persists preferences to the debuggee, instead of just to the DevTools + * client. See the definition of the preference actor for more information. + */ + get preferenceFront() { + if (!this._preferenceFrontRequest) { + // Set the _preferenceFrontRequest property to allow the resetPreference toolbox + // method to cleanup the preference set when the toolbox is closed. + this._preferenceFrontRequest = + this.commands.client.mainRoot.getFront("preference"); + } + return this._preferenceFrontRequest; + }, + + /** + * See: https://firefox-source-docs.mozilla.org/l10n/fluent/tutorial.html#manually-testing-ui-with-pseudolocalization + * + * @param {"bidi" | "accented" | "none"} pseudoLocale + */ + async changePseudoLocale(pseudoLocale) { + await this.isOpen; + const prefFront = await this.preferenceFront; + if (pseudoLocale === "none") { + await prefFront.clearUserPref(PSEUDO_LOCALE_PREF); + } else { + await prefFront.setCharPref(PSEUDO_LOCALE_PREF, pseudoLocale); + } + this.component.setPseudoLocale(pseudoLocale); + this._pseudoLocaleChanged = true; + }, + + /** + * Returns the pseudo-locale when the target is browser chrome, otherwise undefined. + * + * @returns {"bidi" | "accented" | "none" | undefined} + */ + async getPseudoLocale() { + if (!this.isBrowserToolbox) { + return undefined; + } + + const prefFront = await this.preferenceFront; + const locale = await prefFront.getCharPref(PSEUDO_LOCALE_PREF); + + switch (locale) { + case "bidi": + case "accented": + return locale; + default: + return "none"; + } + }, + + async toggleNoAutohide() { + const front = await this.preferenceFront; + + const toggledValue = !(await this._isDisableAutohideEnabled()); + + front.setBoolPref(DISABLE_AUTOHIDE_PREF, toggledValue); + + if ( + this.isBrowserToolbox || + this._descriptorFront.isWebExtensionDescriptor + ) { + this.component.setDisableAutohide(toggledValue); + } + this._autohideHasBeenToggled = true; + }, + + /** + * Toggling "always on top" behavior is a bit special. + * + * We toggle the preference and then destroy and re-create the toolbox + * as there is no way to change this behavior on an existing window + * (see bug 1788946). + */ + async toggleAlwaysOnTop() { + const currentValue = Services.prefs.getBoolPref( + DEVTOOLS_ALWAYS_ON_TOP, + false + ); + Services.prefs.setBoolPref(DEVTOOLS_ALWAYS_ON_TOP, !currentValue); + + const addonId = this._descriptorFront.id; + await this.destroy(); + gDevTools.showToolboxForWebExtension(addonId); + }, + + async _isDisableAutohideEnabled() { + if ( + !this.isBrowserToolbox && + !this._descriptorFront.isWebExtensionDescriptor + ) { + return false; + } + + const prefFront = await this.preferenceFront; + return prefFront.getBoolPref(DISABLE_AUTOHIDE_PREF); + }, + + async _listFrames(event) { + if ( + !this.target.getTrait("frames") || + this.target.targetForm.ignoreSubFrames + ) { + // We are not targetting a regular WindowGlobalTargetActor (it can be either an + // addon or browser toolbox actor), or EFT is enabled. + return; + } + + try { + const { frames } = await this.target.listFrames(); + this._updateFrames({ frames }); + } catch (e) { + console.error("Error while listing frames", e); + } + }, + + /** + * Called by the iframe picker when the user selected a frame. + * + * @param {String} frameIdOrTargetActorId + */ + onIframePickerFrameSelected(frameIdOrTargetActorId) { + if (!this.frameMap.has(frameIdOrTargetActorId)) { + console.error( + `Can't focus on frame "${frameIdOrTargetActorId}", it is not a known frame` + ); + return; + } + + const frameInfo = this.frameMap.get(frameIdOrTargetActorId); + // If there is no targetFront in the frameData, this means EFT is not enabled. + // Send packet to the backend to select specified frame and wait for 'frameUpdate' + // event packet to update the UI. + if (!frameInfo.targetFront) { + this.target.switchToFrame({ windowId: frameIdOrTargetActorId }); + return; + } + + // Here, EFT is enabled, so we want to focus the toolbox on the specific targetFront + // that was selected by the user. This will trigger this._onTargetSelected which will + // take care of updating the iframe picker state. + this.commands.targetCommand.selectTarget(frameInfo.targetFront); + }, + + /** + * Highlight a frame in the page + * + * @param {String} frameIdOrTargetActorId + */ + async onHighlightFrame(frameIdOrTargetActorId) { + // Only enable frame highlighting when the top level document is targeted + if (!this.rootFrameSelected) { + return null; + } + + const frameInfo = this.frameMap.get(frameIdOrTargetActorId); + if (!frameInfo) { + return null; + } + + let nodeFront; + if (frameInfo.targetFront) { + const inspectorFront = await frameInfo.targetFront.getFront("inspector"); + nodeFront = await inspectorFront.walker.documentElement(); + } else { + const inspectorFront = await this.target.getFront("inspector"); + nodeFront = await inspectorFront.walker.getNodeActorFromWindowID( + frameIdOrTargetActorId + ); + } + const highlighter = this.getHighlighter(); + return highlighter.highlight(nodeFront); + }, + + /** + * Handles changes in document frames. + * + * @param {Object} data + * @param {Boolean} data.destroyAll: All frames have been destroyed. + * @param {Number} data.selected: A frame has been selected + * @param {Object} data.frameData: Some frame data were updated + * @param {String} data.frameData.url: new frame URL (it might have been blank or about:blank) + * @param {String} data.frameData.title: new frame title + * @param {Number|String} data.frameData.id: frame ID / targetFront actorID when EFT is enabled. + * @param {Array<Object>} data.frames: List of frames. Every frame can have: + * @param {Number|String} data.frames[].id: frame ID / targetFront actorID when EFT is enabled. + * @param {String} data.frames[].url: frame URL + * @param {String} data.frames[].title: frame title + * @param {Boolean} data.frames[].destroy: Set to true if destroyed + * @param {Boolean} data.frames[].isTopLevel: true for top level window + */ + _updateFrames(data) { + // At the moment, frames `id` can either be outerWindowID (a Number), + // or a targetActorID (a String). + // In order to have the same type of data as a key of `frameMap`, we transform any + // outerWindowID into a string. + // This can be removed once EFT is enabled by default + if (data.selected) { + data.selected = data.selected.toString(); + } else if (data.frameData) { + data.frameData.id = data.frameData.id.toString(); + } else if (data.frames) { + data.frames.forEach(frame => { + if (frame.id) { + frame.id = frame.id.toString(); + } + }); + } + + // Store (synchronize) data about all existing frames on the backend + if (data.destroyAll) { + this.frameMap.clear(); + this.selectedFrameId = null; + } else if (data.selected) { + // If we select the top level target, default back to no particular selected document. + if (data.selected == this.target.actorID) { + this.selectedFrameId = null; + } else { + this.selectedFrameId = data.selected; + } + } else if (data.frameData && this.frameMap.has(data.frameData.id)) { + const existingFrameData = this.frameMap.get(data.frameData.id); + if ( + existingFrameData.title == data.frameData.title && + existingFrameData.url == data.frameData.url + ) { + return; + } + + this.frameMap.set(data.frameData.id, { + ...existingFrameData, + url: data.frameData.url, + title: data.frameData.title, + }); + } else if (data.frames) { + data.frames.forEach(frame => { + if (frame.destroy) { + this.frameMap.delete(frame.id); + + // Reset the currently selected frame if it's destroyed. + if (this.selectedFrameId == frame.id) { + this.selectedFrameId = null; + } + } else { + this.frameMap.set(frame.id, frame); + } + }); + } + + // If there is no selected frame select the first top level + // frame by default. Note that there might be more top level + // frames in case of the BrowserToolbox. + if (!this.selectedFrameId) { + const frames = [...this.frameMap.values()]; + const topFrames = frames.filter(frame => frame.isTopLevel); + this.selectedFrameId = topFrames.length ? topFrames[0].id : null; + } + + // Debounce the update to avoid unnecessary flickering/rendering. + if (!this.debouncedToolbarUpdate) { + this.debouncedToolbarUpdate = debounce( + () => { + // Toolbox may have been destroyed in the meantime + if (this.component) { + this.component.setToolboxButtons(this.toolbarButtons); + } + this.debouncedToolbarUpdate = null; + }, + 200, + this + ); + } + + const updateUiElements = () => { + // We may need to hide/show the frames button now. + this.updateFrameButton(); + + if (this.debouncedToolbarUpdate) { + this.debouncedToolbarUpdate(); + } + }; + + // This may have been called before the toolbox is ready (= the dom elements for + // the iframe picker don't exist yet). + if (!this.isReady) { + this.once("ready").then(() => updateUiElements); + } else { + updateUiElements(); + } + }, + + /** + * Returns whether a root frame (with no parent frame) is selected. + */ + get rootFrameSelected() { + // If the frame switcher is disabled, we won't have a selected frame ID. + // In this case, we're always showing the root frame. + if (!this.selectedFrameId) { + return true; + } + + return this.frameMap.get(this.selectedFrameId).isTopLevel; + }, + + /** + * Switch to the last used host for the toolbox UI. + */ + switchToPreviousHost() { + return this.switchHost("previous"); + }, + + /** + * Switch to a new host for the toolbox UI. E.g. bottom, sidebar, window, + * and focus the window when done. + * + * @param {string} hostType + * The host type of the new host object + */ + switchHost(hostType) { + if (hostType == this.hostType || !this._descriptorFront.isLocalTab) { + return null; + } + + // chromeEventHandler will change after swapping hosts, remove events relying on it. + this._removeChromeEventHandlerEvents(); + + this.emit("host-will-change", hostType); + + // ToolboxHostManager is going to call swapFrameLoaders which mess up with + // focus. We have to blur before calling it in order to be able to restore + // the focus after, in _onSwitchedHost. + this.focusTool(this.currentToolId, false); + + // Host code on the chrome side will send back a message once the host + // switched + this.postMessage({ + name: "switch-host", + hostType, + }); + + return this.once("host-changed"); + }, + + /** + * Request to Firefox UI to move the toolbox to another tab. + * This is used when we move a toolbox to a new popup opened by the tab we were currently debugging. + * We also move the toolbox back to the original tab we were debugging if we select it via Firefox tabs. + * + * @param {String} tabBrowsingContextID + * The BrowsingContext ID of the tab we want to move to. + * @returns {Promise<undefined>} + * This will resolve only once we moved to the new tab. + */ + switchHostToTab(tabBrowsingContextID) { + this.postMessage({ + name: "switch-host-to-tab", + tabBrowsingContextID, + }); + + return this.once("switched-host-to-tab"); + }, + + _onSwitchedHost({ hostType }) { + this._hostType = hostType; + + this._buildDockOptions(); + + // chromeEventHandler changed after swapping hosts, add again events relying on it. + this._addChromeEventHandlerEvents(); + + // We blurred the tools at start of switchHost, but also when clicking on + // host switching button. We now have to restore the focus. + this.focusTool(this.currentToolId, true); + + this.emit("host-changed"); + this.telemetry + .getHistogramById(HOST_HISTOGRAM) + .add(this._getTelemetryHostId()); + + this.component.setCurrentHostType(hostType); + }, + + /** + * Event handler fired when the toolbox was moved to another tab. + * This fires when the toolbox itself requests to be moved to another tab, + * but also when we select the original tab where the toolbox originally was. + * + * @param {String} browsingContextID + * The BrowsingContext ID of the tab the toolbox has been moved to. + */ + _onSwitchedHostToTab(browsingContextID) { + const targets = this.commands.targetCommand.getAllTargets([ + this.commands.targetCommand.TYPES.FRAME, + ]); + const target = targets.find( + target => target.browsingContextID == browsingContextID + ); + + this.commands.targetCommand.selectTarget(target); + + this.emit("switched-host-to-tab"); + }, + + /** + * Test the availability of a tool (both globally registered tools and + * additional tools registered to this toolbox) by tool id. + * + * @param {string} toolId + * Id of the tool definition to search in the per-toolbox or globally + * registered tools. + * + * @returns {bool} + * Returns true if the tool is registered globally or on this toolbox. + */ + isToolRegistered(toolId) { + return !!this.getToolDefinition(toolId); + }, + + /** + * Return the tool definition registered globally or additional tools registered + * to this toolbox. + * + * @param {string} toolId + * Id of the tool definition to retrieve for the per-toolbox and globally + * registered tools. + * + * @returns {object} + * The plain javascript object that represents the requested tool definition. + */ + getToolDefinition(toolId) { + return ( + gDevTools.getToolDefinition(toolId) || + this.additionalToolDefinitions.get(toolId) + ); + }, + + /** + * Internal helper that removes a loaded tool from the toolbox, + * it removes a loaded tool panel and tab from the toolbox without removing + * its definition, so that it can still be listed in options and re-added later. + * + * @param {string} toolId + * Id of the tool to be removed. + */ + unloadTool(toolId) { + if (typeof toolId != "string") { + throw new Error("Unexpected non-string toolId received."); + } + + if (this._toolPanels.has(toolId)) { + const instance = this._toolPanels.get(toolId); + instance.destroy(); + this._toolPanels.delete(toolId); + } + + const panel = this.doc.getElementById("toolbox-panel-" + toolId); + + // Select another tool. + if (this.currentToolId == toolId) { + const index = this.panelDefinitions.findIndex(({ id }) => id === toolId); + const nextTool = this.panelDefinitions[index + 1]; + const previousTool = this.panelDefinitions[index - 1]; + let toolNameToSelect; + + if (nextTool) { + toolNameToSelect = nextTool.id; + } + if (previousTool) { + toolNameToSelect = previousTool.id; + } + if (toolNameToSelect) { + this.selectTool(toolNameToSelect, "tool_unloaded"); + } + } + + // Remove this tool from the current panel definitions. + this.panelDefinitions = this.panelDefinitions.filter( + ({ id }) => id !== toolId + ); + this.visibleAdditionalTools = this.visibleAdditionalTools.filter( + id => id !== toolId + ); + this._combineAndSortPanelDefinitions(); + + if (panel) { + panel.remove(); + } + + if (this.hostType == Toolbox.HostType.WINDOW) { + const doc = this.win.parent.document; + const key = doc.getElementById("key_" + toolId); + if (key) { + key.remove(); + } + } + }, + + /** + * Handler for the tool-registered event. + * @param {string} toolId + * Id of the tool that was registered + */ + _toolRegistered(toolId) { + // Tools can either be in the global devtools, or added to this specific toolbox + // as an additional tool. + let definition = gDevTools.getToolDefinition(toolId); + let isAdditionalTool = false; + if (!definition) { + definition = this.additionalToolDefinitions.get(toolId); + isAdditionalTool = true; + } + + if (definition.isToolSupported(this)) { + if (isAdditionalTool) { + this.visibleAdditionalTools = [...this.visibleAdditionalTools, toolId]; + this._combineAndSortPanelDefinitions(); + } else { + this.panelDefinitions = this.panelDefinitions.concat(definition); + } + this._buildPanelForTool(definition); + + // Emit the event so tools can listen to it from the toolbox level + // instead of gDevTools. + this.emit("tool-registered", toolId); + } + }, + + /** + * Handler for the tool-unregistered event. + * @param {string} toolId + * id of the tool that was unregistered + */ + _toolUnregistered(toolId) { + this.unloadTool(toolId); + + // Emit the event so tools can listen to it from the toolbox level + // instead of gDevTools + this.emit("tool-unregistered", toolId); + }, + + /** + * A helper function that returns an object containing methods to show and hide the + * Box Model Highlighter on a given NodeFront or node grip (object with metadata which + * can be used to obtain a NodeFront for a node), as well as helpers to listen to the + * higligher show and hide events. The event helpers are used in tests where it is + * cumbersome to load the Inspector panel in order to listen to highlighter events. + * + * @returns {Object} an object of the following shape: + * - {AsyncFunction} highlight: A function that will show a Box Model Highlighter + * for the provided NodeFront or node grip. + * - {AsyncFunction} unhighlight: A function that will hide any Box Model Highlighter + * that is visible. If the `highlight` promise isn't settled yet, + * it will wait until it's done and then unhighlight to prevent + * zombie highlighters. + * - {AsyncFunction} waitForHighlighterShown: Returns a promise which resolves with + * the "highlighter-shown" event data once the highlighter is shown. + * - {AsyncFunction} waitForHighlighterHidden: Returns a promise which resolves with + * the "highlighter-hidden" event data once the highlighter is + * hidden. + * + */ + getHighlighter() { + let pendingHighlight; + + /** + * Return a promise wich resolves with a reference to the Inspector panel. + */ + const _getInspector = async () => { + const inspector = this.getPanel("inspector"); + if (inspector) { + return inspector; + } + + return this.loadTool("inspector"); + }; + + /** + * Returns a promise which resolves when a Box Model Highlighter emits the given event + * + * @param {String} eventName + * Name of the event to listen to. + * @return {Promise} + * Promise which resolves when the highlighter event occurs. + * Resolves with the data payload attached to the event. + */ + async function _waitForHighlighterEvent(eventName) { + const inspector = await _getInspector(); + return new Promise(resolve => { + function _handler(data) { + if (data.type === inspector.highlighters.TYPES.BOXMODEL) { + inspector.highlighters.off(eventName, _handler); + resolve(data); + } + } + + inspector.highlighters.on(eventName, _handler); + }); + } + + return { + // highlight might be triggered right before a test finishes. Wrap it + // with safeAsyncMethod to avoid intermittents. + highlight: this._safeAsyncAfterDestroy(async (object, options) => { + pendingHighlight = (async () => { + let nodeFront = object; + + if (!(nodeFront instanceof NodeFront)) { + const inspectorFront = await this.target.getFront("inspector"); + nodeFront = await inspectorFront.getNodeFrontFromNodeGrip(object); + } + + if (!nodeFront) { + return null; + } + + const inspector = await _getInspector(); + return inspector.highlighters.showHighlighterTypeForNode( + inspector.highlighters.TYPES.BOXMODEL, + nodeFront, + options + ); + })(); + return pendingHighlight; + }), + unhighlight: this._safeAsyncAfterDestroy(async () => { + if (pendingHighlight) { + await pendingHighlight; + pendingHighlight = null; + } + + const inspector = await _getInspector(); + return inspector.highlighters.hideHighlighterType( + inspector.highlighters.TYPES.BOXMODEL + ); + }), + + waitForHighlighterShown: this._safeAsyncAfterDestroy(async () => { + return _waitForHighlighterEvent("highlighter-shown"); + }), + + waitForHighlighterHidden: this._safeAsyncAfterDestroy(async () => { + return _waitForHighlighterEvent("highlighter-hidden"); + }), + }; + }, + + /** + * Shortcut to avoid throwing errors when an async method fails after toolbox + * destroy. Should be used with methods that might be triggered by a user + * input, regardless of the toolbox lifecycle. + */ + _safeAsyncAfterDestroy(fn) { + return safeAsyncMethod(fn, () => !!this._destroyer); + }, + + async _onNewSelectedNodeFront() { + // Emit a "selection-changed" event when the toolbox.selection has been set + // to a new node (or cleared). Currently used in the WebExtensions APIs (to + // provide the `devtools.panels.elements.onSelectionChanged` event). + this.emit("selection-changed"); + + const targetFrontActorID = this.selection?.nodeFront?.targetFront?.actorID; + if (targetFrontActorID) { + this.selectTarget(targetFrontActorID); + } + }, + + _onToolSelected() { + this._refreshHostTitle(); + + this.updatePickerButton(); + this.updateFrameButton(); + this.updateErrorCountButton(); + + // Calling setToolboxButtons in case the visibility of a button changed. + this.component.setToolboxButtons(this.toolbarButtons); + }, + + /** + * Listener for "inspectObject" event on console top level target actor. + */ + _onInspectObject(packet) { + this.inspectObjectActor(packet.objectActor, packet.inspectFromAnnotation); + }, + + async inspectObjectActor(objectActor, inspectFromAnnotation) { + const objectGrip = objectActor?.getGrip + ? objectActor.getGrip() + : objectActor; + + if ( + objectGrip.preview && + objectGrip.preview.nodeType === domNodeConstants.ELEMENT_NODE + ) { + await this.viewElementInInspector(objectGrip, inspectFromAnnotation); + return; + } + + if (objectGrip.class == "Function") { + if (!objectGrip.location) { + console.error("Missing location in Function objectGrip", objectGrip); + return; + } + + const { url, line, column } = objectGrip.location; + await this.viewSourceInDebugger(url, line, column); + return; + } + + if (objectGrip.type !== "null" && objectGrip.type !== "undefined") { + // Open then split console and inspect the object in the variables view, + // when the objectActor doesn't represent an undefined or null value. + if (this.currentToolId != "webconsole") { + await this.openSplitConsole(); + } + + const panel = this.getPanel("webconsole"); + panel.hud.ui.inspectObjectActor(objectActor); + } + }, + + /** + * Get the toolbox's notification component + * + * @return The notification box component. + */ + getNotificationBox() { + return this.notificationBox; + }, + + async closeToolbox() { + await this.destroy(); + }, + + /** + * Public API to check is the current toolbox is currently being destroyed. + */ + isDestroying() { + return this._destroyer; + }, + + /** + * Remove all UI elements, detach from target and clear up + */ + destroy() { + // If several things call destroy then we give them all the same + // destruction promise so we're sure to destroy only once + if (this._destroyer) { + return this._destroyer; + } + + // This pattern allows to immediately return the destroyer promise. + // See Bug 1602727 for more details. + let destroyerResolve; + this._destroyer = new Promise(r => (destroyerResolve = r)); + this._destroyToolbox().then(destroyerResolve); + + return this._destroyer; + }, + + async _destroyToolbox() { + this.emit("destroy"); + + // This flag will be checked by Fronts in order to decide if they should + // skip their destroy. + this.commands.client.isToolboxDestroy = true; + + this.off("select", this._onToolSelected); + this.off("host-changed", this._refreshHostTitle); + + gDevTools.off("tool-registered", this._toolRegistered); + gDevTools.off("tool-unregistered", this._toolUnregistered); + + Services.prefs.removeObserver( + "devtools.cache.disabled", + this._applyCacheSettings + ); + Services.prefs.removeObserver( + "devtools.custom-formatters.enabled", + this._applyCustomFormatterSetting + ); + Services.prefs.removeObserver( + "devtools.serviceWorkers.testing.enabled", + this._applyServiceWorkersTestingSettings + ); + Services.prefs.removeObserver( + "devtools.inspector.simple-highlighters-reduced-motion", + this._applySimpleHighlightersSettings + ); + Services.prefs.removeObserver( + BROWSERTOOLBOX_SCOPE_PREF, + this._refreshHostTitle + ); + + // We normally handle toolClosed from selectTool() but in the event of the + // toolbox closing we need to handle it here instead. + this.telemetry.toolClosed(this.currentToolId, this); + + this._lastFocusedElement = null; + this._pausedTargets = null; + + if (this._sourceMapLoader) { + this._sourceMapLoader.destroy(); + this._sourceMapLoader = null; + } + + if (this._parserWorker) { + this._parserWorker.stop(); + this._parserWorker = null; + } + + if (this.webconsolePanel) { + this._saveSplitConsoleHeight(); + this.webconsolePanel.removeEventListener( + "resize", + this._saveSplitConsoleHeight + ); + this.webconsolePanel = null; + } + if (this._componentMount) { + this._tabBar.removeEventListener( + "keypress", + this._onToolbarArrowKeypress + ); + this.ReactDOM.unmountComponentAtNode(this._componentMount); + this.component = null; + this._componentMount = null; + this._tabBar = null; + } + this.destroyHarAutomation(); + + for (const [id, panel] of this._toolPanels) { + try { + gDevTools.emit(id + "-destroy", this, panel); + this.emit(id + "-destroy", panel); + + const rv = panel.destroy(); + if (rv) { + console.error( + `Panel ${id}'s destroy method returned something whereas it shouldn't (and should be synchronous).` + ); + } + } catch (e) { + // We don't want to stop here if any panel fail to close. + console.error("Panel " + id + ":", e); + } + } + + this.browserRequire = null; + this._toolNames = null; + + // Reset preferences set by the toolbox, then remove the preference front. + const onResetPreference = this.resetPreference().then(() => { + this._preferenceFrontRequest = null; + }); + + this.commands.targetCommand.unwatchTargets({ + types: this.commands.targetCommand.ALL_TYPES, + onAvailable: this._onTargetAvailable, + onSelected: this._onTargetSelected, + onDestroyed: this._onTargetDestroyed, + }); + + const watchedResources = [ + this.resourceCommand.TYPES.CONSOLE_MESSAGE, + this.resourceCommand.TYPES.ERROR_MESSAGE, + this.resourceCommand.TYPES.DOCUMENT_EVENT, + this.resourceCommand.TYPES.THREAD_STATE, + ]; + + if (!this.isBrowserToolbox) { + watchedResources.push(this.resourceCommand.TYPES.NETWORK_EVENT); + } + + this.resourceCommand.unwatchResources(watchedResources, { + onAvailable: this._onResourceAvailable, + }); + + // Unregister buttons listeners + this.toolbarButtons.forEach(button => { + if (typeof button.teardown == "function") { + // teardown arguments have already been bound in _createButtonState + button.teardown(); + } + }); + + // We need to grab a reference to win before this._host is destroyed. + const win = this.win; + const host = this._getTelemetryHostString(); + const width = Math.ceil(win.outerWidth / 50) * 50; + const prevPanelName = this.getTelemetryPanelNameOrOther(this.currentToolId); + + this.telemetry.toolClosed("toolbox", this); + this.telemetry.recordEvent("exit", prevPanelName, null, { + host, + width, + panel_name: this.getTelemetryPanelNameOrOther(this.currentToolId), + next_panel: "none", + reason: "toolbox_close", + }); + this.telemetry.recordEvent("close", "tools", null, { + host, + width, + }); + + // Wait for the preferences to be reset before destroying the target descriptor (which will destroy the preference front) + const onceDestroyed = new Promise(resolve => { + resolve( + onResetPreference + .catch(console.error) + .then(async () => { + // Destroy the node picker *after* destroying the panel, + // which may still try to access it. (And might spawn a new one) + if (this._nodePicker) { + this._nodePicker.destroy(); + this._nodePicker = null; + } + this.selection.destroy(); + this.selection = null; + + if (this._netMonitorAPI) { + this._netMonitorAPI.destroy(); + this._netMonitorAPI = null; + } + + if (this._sourceMapURLService) { + await this._sourceMapURLService.waitForSourcesLoading(); + this._sourceMapURLService.destroy(); + this._sourceMapURLService = null; + } + + this._removeWindowListeners(); + this._removeChromeEventHandlerEvents(); + + this._store = null; + + // All Commands need to be destroyed. + // This is done after other destruction tasks since it may tear down + // fronts and the debugger transport which earlier destroy methods may + // require to complete. + // (i.e. avoid exceptions about closing connection with pending requests) + // + // For similar reasons, only destroy the TargetCommand after every + // other outstanding cleanup is done. Destroying the target list + // will lead to destroy frame targets which can temporarily make + // some fronts unresponsive and block the cleanup. + return this.commands.destroy(); + }, console.error) + .then(() => { + this.emit("destroyed"); + + // Free _host after the call to destroyed in order to let a chance + // to destroyed listeners to still query toolbox attributes + this._host = null; + this._win = null; + this._toolPanels.clear(); + this._descriptorFront = null; + this.resourceCommand = null; + this.commands = null; + + // Force GC to prevent long GC pauses when running tests and to free up + // memory in general when the toolbox is closed. + if (flags.testing) { + win.windowUtils.garbageCollect(); + } + }) + .catch(console.error) + ); + }); + + const leakCheckObserver = ({ wrappedJSObject: barrier }) => { + // Make the leak detector wait until this toolbox is properly destroyed. + barrier.client.addBlocker( + "DevTools: Wait until toolbox is destroyed", + onceDestroyed + ); + }; + + const topic = "shutdown-leaks-before-check"; + Services.obs.addObserver(leakCheckObserver, topic); + + await onceDestroyed; + + Services.obs.removeObserver(leakCheckObserver, topic); + }, + + /** + * Open the textbox context menu at given coordinates. + * Panels in the toolbox can call this on contextmenu events with event.screenX/Y + * instead of having to implement their own copy/paste/selectAll menu. + * @param {Number} x + * @param {Number} y + */ + openTextBoxContextMenu(x, y) { + const menu = createEditContextMenu(this.topWindow, "toolbox-menu"); + + // Fire event for tests + menu.once("open", () => this.emit("menu-open")); + menu.once("close", () => this.emit("menu-close")); + + menu.popup(x, y, this.doc); + }, + + /** + * Retrieve the current textbox context menu, if available. + */ + getTextBoxContextMenu() { + return this.topDoc.getElementById("toolbox-menu"); + }, + + /** + * Reset preferences set by the toolbox. + */ + async resetPreference() { + if ( + // No preferences have been changed, so there is nothing to reset. + !this._preferenceFrontRequest || + // Did any pertinent prefs actually change? For autohide and the pseudo-locale, + // only reset prefs in the Browser Toolbox if it's been toggled in the UI + // (don't reset the pref if it was already set before opening) + (!this._autohideHasBeenToggled && !this._pseudoLocaleChanged) + ) { + return; + } + + const preferenceFront = await this.preferenceFront; + + if (this._autohideHasBeenToggled) { + await preferenceFront.clearUserPref(DISABLE_AUTOHIDE_PREF); + } + if (this._pseudoLocaleChanged) { + await preferenceFront.clearUserPref(PSEUDO_LOCALE_PREF); + } + }, + + // HAR Automation + + async initHarAutomation() { + const autoExport = Services.prefs.getBoolPref( + "devtools.netmonitor.har.enableAutoExportToFile" + ); + if (autoExport) { + this.harAutomation = new HarAutomation(); + await this.harAutomation.initialize(this); + } + }, + destroyHarAutomation() { + if (this.harAutomation) { + this.harAutomation.destroy(); + } + }, + + /** + * Returns gViewSourceUtils for viewing source. + */ + get gViewSourceUtils() { + return this.win.gViewSourceUtils; + }, + + /** + * Open a CSS file when there is no line or column information available. + * + * @param {string} url The URL of the CSS file to open. + */ + async viewGeneratedSourceInStyleEditor(url) { + if (typeof url !== "string") { + console.warn("Failed to open generated source, no url given"); + return false; + } + + // The style editor hides the generated file if the file has original + // sources, so we have no choice but to open whichever original file + // corresponds to the first line of the generated file. + return viewSource.viewSourceInStyleEditor(this, url, 1); + }, + + /** + * Given a URL for a stylesheet (generated or original), open in the style + * editor if possible. Falls back to plain "view-source:". + * If the stylesheet has a sourcemap, we will attempt to open the original + * version of the file instead of the generated version. + */ + async viewSourceInStyleEditorByURL(url, line, column) { + if (typeof url !== "string") { + console.warn("Failed to open source, no url given"); + return false; + } + if (typeof line !== "number") { + console.warn( + "No line given when navigating to source. If you're seeing this, there is a bug." + ); + + // This is a fallback in case of programming errors, but in a perfect + // world, viewSourceInStyleEditorByURL would always get a line/colum. + line = 1; + column = null; + } + + return viewSource.viewSourceInStyleEditor(this, url, line, column); + }, + + /** + * Opens source in style editor. Falls back to plain "view-source:". + * If the stylesheet has a sourcemap, we will attempt to open the original + * version of the file instead of the generated version. + */ + async viewSourceInStyleEditorByResource(stylesheetResource, line, column) { + if (!stylesheetResource || typeof stylesheetResource !== "object") { + console.warn("Failed to open source, no stylesheet given"); + return false; + } + if (typeof line !== "number") { + console.warn( + "No line given when navigating to source. If you're seeing this, there is a bug." + ); + + // This is a fallback in case of programming errors, but in a perfect + // world, viewSourceInStyleEditorByResource would always get a line/colum. + line = 1; + column = null; + } + + return viewSource.viewSourceInStyleEditor( + this, + stylesheetResource, + line, + column + ); + }, + + async viewElementInInspector(objectGrip, reason) { + // Open the inspector and select the DOM Element. + await this.loadTool("inspector"); + const inspector = this.getPanel("inspector"); + const nodeFound = await inspector.inspectNodeActor(objectGrip, reason); + if (nodeFound) { + await this.selectTool("inspector", reason); + } + }, + + /** + * Open a JS file when there is no line or column information available. + * + * @param {string} url The URL of the JS file to open. + */ + async viewGeneratedSourceInDebugger(url) { + if (typeof url !== "string") { + console.warn("Failed to open generated source, no url given"); + return false; + } + + return viewSource.viewSourceInDebugger(this, url, null, null, null, null); + }, + + /** + * Opens source in debugger, the sourcemapped location will be selected in + * the debugger panel, if the given location resolves to a know sourcemapped one. + * + * Falls back to plain "view-source:". + * + * @see devtools/client/shared/source-utils.js + */ + async viewSourceInDebugger( + sourceURL, + sourceLine, + sourceColumn, + sourceId, + reason + ) { + if (typeof sourceURL !== "string" && typeof sourceId !== "string") { + console.warn("Failed to open generated source, no url/id given"); + return false; + } + if (typeof sourceLine !== "number") { + console.warn( + "No line given when navigating to source. If you're seeing this, there is a bug." + ); + + // This is a fallback in case of programming errors, but in a perfect + // world, viewSourceInDebugger would always get a line/colum. + sourceLine = 1; + sourceColumn = null; + } + + return viewSource.viewSourceInDebugger( + this, + sourceURL, + sourceLine, + sourceColumn, + sourceId, + reason + ); + }, + + /** + * Opens source in plain "view-source:". + * @see devtools/client/shared/source-utils.js + */ + viewSource(sourceURL, sourceLine) { + return viewSource.viewSource(this, sourceURL, sourceLine); + }, + + // Support for WebExtensions API (`devtools.network.*`) + + /** + * Return Netmonitor API object. This object offers Network monitor + * public API that can be consumed by other panels or WE API. + */ + async getNetMonitorAPI() { + const netPanel = this.getPanel("netmonitor"); + + // Return Net panel if it exists. + if (netPanel) { + return netPanel.panelWin.Netmonitor.api; + } + + if (this._netMonitorAPI) { + return this._netMonitorAPI; + } + + // Create and initialize Network monitor API object. + // This object is only connected to the backend - not to the UI. + this._netMonitorAPI = new NetMonitorAPI(); + await this._netMonitorAPI.connect(this); + + return this._netMonitorAPI; + }, + + /** + * Returns data (HAR) collected by the Network panel. + */ + async getHARFromNetMonitor() { + const netMonitor = await this.getNetMonitorAPI(); + let har = await netMonitor.getHar(); + + // Return default empty HAR file if needed. + har = har || buildHarLog(Services.appinfo); + + // Return the log directly to be compatible with + // Chrome WebExtension API. + return har.log; + }, + + /** + * Add listener for `onRequestFinished` events. + * + * @param {Object} listener + * The listener to be called it's expected to be + * a function that takes ({harEntry, requestId}) + * as first argument. + */ + async addRequestFinishedListener(listener) { + const netMonitor = await this.getNetMonitorAPI(); + netMonitor.addRequestFinishedListener(listener); + }, + + async removeRequestFinishedListener(listener) { + const netMonitor = await this.getNetMonitorAPI(); + netMonitor.removeRequestFinishedListener(listener); + + // Destroy Network monitor API object if the following is true: + // 1) there is no listener + // 2) the Net panel doesn't exist/use the API object (if the panel + // exists it's also responsible for destroying it, + // see `NetMonitorPanel.open` for more details) + const netPanel = this.getPanel("netmonitor"); + const hasListeners = netMonitor.hasRequestFinishedListeners(); + if (this._netMonitorAPI && !hasListeners && !netPanel) { + this._netMonitorAPI.destroy(); + this._netMonitorAPI = null; + } + }, + + /** + * Used to lazily fetch HTTP response content within + * `onRequestFinished` event listener. + * + * @param {String} requestId + * Id of the request for which the response content + * should be fetched. + */ + async fetchResponseContent(requestId) { + const netMonitor = await this.getNetMonitorAPI(); + return netMonitor.fetchResponseContent(requestId); + }, + + // Support management of installed WebExtensions that provide a devtools_page. + + /** + * List the subset of the active WebExtensions which have a devtools_page (used by + * toolbox-options.js to create the list of the tools provided by the enabled + * WebExtensions). + * @see devtools/client/framework/toolbox-options.js + */ + listWebExtensions() { + // Return the array of the enabled webextensions (we can't use the prefs list here, + // because some of them may be disabled by the Addon Manager and still have a devtools + // preference). + return Array.from(this._webExtensions).map(([uuid, { name, pref }]) => { + return { uuid, name, pref }; + }); + }, + + /** + * Add a WebExtension to the list of the active extensions (given the extension UUID, + * a unique id assigned to an extension when it is installed, and its name), + * and emit a "webextension-registered" event to allow toolbox-options.js + * to refresh the listed tools accordingly. + * @see browser/components/extensions/ext-devtools.js + */ + registerWebExtension(extensionUUID, { name, pref }) { + // Ensure that an installed extension (active in the AddonManager) which + // provides a devtools page is going to be listed in the toolbox options + // (and refresh its name if it was already listed). + this._webExtensions.set(extensionUUID, { name, pref }); + this.emit("webextension-registered", extensionUUID); + }, + + /** + * Remove an active WebExtension from the list of the active extensions (given the + * extension UUID, a unique id assigned to an extension when it is installed, and its + * name), and emit a "webextension-unregistered" event to allow toolbox-options.js + * to refresh the listed tools accordingly. + * @see browser/components/extensions/ext-devtools.js + */ + unregisterWebExtension(extensionUUID) { + // Ensure that an extension that has been disabled/uninstalled from the AddonManager + // is going to be removed from the toolbox options. + this._webExtensions.delete(extensionUUID); + this.emit("webextension-unregistered", extensionUUID); + }, + + /** + * A helper function which returns true if the extension with the given UUID is listed + * as active for the toolbox and has its related devtools about:config preference set + * to true. + * @see browser/components/extensions/ext-devtools.js + */ + isWebExtensionEnabled(extensionUUID) { + const extInfo = this._webExtensions.get(extensionUUID); + return extInfo && Services.prefs.getBoolPref(extInfo.pref, false); + }, + + /** + * Returns a panel id in the case of built in panels or "other" in the case of + * third party panels. This is necessary due to limitations in addon id strings, + * the permitted length of event telemetry property values and what we actually + * want to see in our telemetry. + * + * @param {String} id + * The panel id we would like to process. + */ + getTelemetryPanelNameOrOther(id) { + if (!this._toolNames) { + const definitions = gDevTools.getToolDefinitionArray(); + const definitionIds = definitions.map(definition => definition.id); + + this._toolNames = new Set(definitionIds); + } + + if (!this._toolNames.has(id)) { + return "other"; + } + + return id; + }, + + /** + * Sets basic information on the DebugTargetInfo component + */ + _setDebugTargetData() { + // Note that local WebExtension are debugged via WINDOW host, + // but we still want to display target data. + if ( + this.hostType === Toolbox.HostType.PAGE || + this._descriptorFront.isWebExtensionDescriptor + ) { + // Displays DebugTargetInfo which shows the basic information of debug target, + // if `about:devtools-toolbox` URL opens directly. + // DebugTargetInfo requires this._debugTargetData to be populated + this.component.setDebugTargetData(this._getDebugTargetData()); + } + }, + + _onResourceAvailable(resources) { + let errors = this._errorCount || 0; + + const { TYPES } = this.resourceCommand; + for (const resource of resources) { + const { resourceType } = resource; + if ( + resourceType === TYPES.ERROR_MESSAGE && + // ERROR_MESSAGE resources can be warnings/info, but here we only want to count errors + resource.pageError.error + ) { + errors++; + continue; + } + + if (resourceType === TYPES.CONSOLE_MESSAGE) { + const { level } = resource.message; + if (level === "error" || level === "exception" || level === "assert") { + errors++; + } + + // Reset the count on console.clear + if (level === "clear") { + errors = 0; + } + } + + // Only consider top level document, and ignore remote iframes top document + if ( + resourceType === TYPES.DOCUMENT_EVENT && + resource.name === "will-navigate" && + resource.targetFront.isTopLevel + ) { + this._onWillNavigate({ + isFrameSwitching: resource.isFrameSwitching, + }); + // While we will call `setErrorCount(0)` from onWillNavigate, we also need to reset + // `errors` local variable in order to clear previous errors processed in the same + // throttling bucket as this will-navigate resource. + errors = 0; + } + + if ( + resourceType === TYPES.DOCUMENT_EVENT && + !resource.isFrameSwitching && + // `url` is set on the targetFront when we receive dom-loading, and `title` when + // `dom-interactive` is received. Here we're only updating the window title in + // the "newer" event. + resource.name === "dom-interactive" + ) { + // the targetFront title and url are updated on dom-interactive, so delay refreshing + // the host title a bit in order for the event listener in targetCommand to be + // executed. + setTimeout(() => { + if (resource.targetFront.isDestroyed()) { + // The resource's target might have been destroyed in between and + // would no longer have a valid actorID available. + return; + } + + this._updateFrames({ + frameData: { + id: resource.targetFront.actorID, + url: resource.targetFront.url, + title: resource.targetFront.title, + }, + }); + + if (resource.targetFront.isTopLevel) { + this._refreshHostTitle(); + this._setDebugTargetData(); + } + }, 0); + } + + if (resourceType == TYPES.THREAD_STATE) { + this._onThreadStateChanged(resource); + } + if (resourceType == TYPES.JSTRACER_STATE) { + this._onTracingStateChanged(resource); + } + } + + this.setErrorCount(errors); + }, + + _onResourceUpdated(resources) { + let errors = this._errorCount || 0; + + for (const { update } of resources) { + // In order to match webconsole behaviour, we treat 4xx and 5xx network calls as errors. + if ( + update.resourceType === this.resourceCommand.TYPES.NETWORK_EVENT && + update.resourceUpdates.status && + update.resourceUpdates.status.toString().match(REGEX_4XX_5XX) + ) { + errors++; + } + } + + this.setErrorCount(errors); + }, + + /** + * Set the number of errors in the toolbar icon. + * + * @param {Number} count + */ + setErrorCount(count) { + // Don't re-render if the number of errors changed + if (!this.component || this._errorCount === count) { + return; + } + + this._errorCount = count; + + // Update button properties and trigger a render of the toolbox + this.updateErrorCountButton(); + this._throttledSetToolboxButtons(); + }, +}; diff --git a/devtools/client/framework/toolbox.xhtml b/devtools/client/framework/toolbox.xhtml new file mode 100644 index 0000000000..3c087f7c0d --- /dev/null +++ b/devtools/client/framework/toolbox.xhtml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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 window> +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + csp="default-src chrome: resource:; img-src chrome: resource: data:; object-src 'none'" + role="application" +> + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link rel="stylesheet" href="chrome://devtools/skin/toolbox.css" /> + <html:link + rel="stylesheet" + href="chrome://devtools/content/shared/components/NotificationBox.css" + /> + <html:link + rel="stylesheet" + href="chrome://devtools/content/framework/components/DebugTargetErrorPage.css" + /> + <html:link + rel="stylesheet" + href="chrome://devtools/content/framework/components/ChromeDebugToolbar.css" + /> + + <html:link rel="localization" href="devtools/client/tooltips.ftl" /> + </linkset> + + <html:link href="chrome://browser/skin/window.svg" rel="shortcut icon" /> + <script src="chrome://devtools/content/shared/theme-switching.js" /> + <script src="chrome://global/content/viewSourceUtils.js" /> + + <script src="chrome://devtools/content/framework/toolbox-init.js" /> + + <vbox id="toolbox-container" role="group"> + <div xmlns="http://www.w3.org/1999/xhtml" id="toolbox-error-mount" /> + <div xmlns="http://www.w3.org/1999/xhtml" id="toolbox-notificationbox" /> + <div + xmlns="http://www.w3.org/1999/xhtml" + id="toolbox-toolbar-mount" + role="toolbar" + /> + <vbox flex="1" class="theme-body"> + <box id="toolbox-deck" /> + <splitter + id="toolbox-console-splitter" + class="devtools-horizontal-splitter" + hidden="true" + /> + <box id="toolbox-panel-webconsole" collapsed="true" /> + </vbox> + <tooltip id="aHTMLTooltip" page="true" /> + </vbox> +</window> |