diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /devtools/client/framework | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/client/framework')
121 files changed, 16997 insertions, 0 deletions
diff --git a/devtools/client/framework/ToolboxProcess.jsm b/devtools/client/framework/ToolboxProcess.jsm new file mode 100644 index 000000000..cd12e92cd --- /dev/null +++ b/devtools/client/framework/ToolboxProcess.jsm @@ -0,0 +1,291 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +const DBG_XUL = "chrome://devtools/content/framework/toolbox-process-window.xul"; +const CHROME_DEBUGGER_PROFILE_NAME = "chrome_debugger_profile"; + +const { require, DevToolsLoader } = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyGetter(this, "Telemetry", function () { + return require("devtools/client/shared/telemetry"); +}); +XPCOMUtils.defineLazyGetter(this, "EventEmitter", function () { + return require("devtools/shared/event-emitter"); +}); +const promise = require("promise"); +const Services = require("Services"); + +this.EXPORTED_SYMBOLS = ["BrowserToolboxProcess"]; + +var processes = new Set(); + +/** + * Constructor for creating a process that will hold a chrome toolbox. + * + * @param function aOnClose [optional] + * A function called when the process stops running. + * @param function aOnRun [optional] + * A function called when the process starts running. + * @param object aOptions [optional] + * An object with properties for configuring BrowserToolboxProcess. + */ +this.BrowserToolboxProcess = function BrowserToolboxProcess(aOnClose, aOnRun, aOptions) { + let emitter = new EventEmitter(); + this.on = emitter.on.bind(emitter); + this.off = emitter.off.bind(emitter); + this.once = emitter.once.bind(emitter); + // Forward any events to the shared emitter. + this.emit = function (...args) { + emitter.emit(...args); + BrowserToolboxProcess.emit(...args); + }; + + // If first argument is an object, use those properties instead of + // all three arguments + if (typeof aOnClose === "object") { + if (aOnClose.onClose) { + this.once("close", aOnClose.onClose); + } + if (aOnClose.onRun) { + this.once("run", aOnClose.onRun); + } + this._options = aOnClose; + } else { + if (aOnClose) { + this.once("close", aOnClose); + } + if (aOnRun) { + this.once("run", aOnRun); + } + this._options = aOptions || {}; + } + + this._telemetry = new Telemetry(); + + this.close = this.close.bind(this); + Services.obs.addObserver(this.close, "quit-application", false); + this._initServer(); + this._initProfile(); + this._create(); + + processes.add(this); +}; + +EventEmitter.decorate(BrowserToolboxProcess); + +/** + * Initializes and starts a chrome toolbox process. + * @return object + */ +BrowserToolboxProcess.init = function (aOnClose, aOnRun, aOptions) { + return new BrowserToolboxProcess(aOnClose, aOnRun, aOptions); +}; + +/** + * Passes a set of options to the BrowserAddonActors for the given ID. + * + * @param aId string + * The ID of the add-on to pass the options to + * @param aOptions object + * The options. + * @return a promise that will be resolved when complete. + */ +BrowserToolboxProcess.setAddonOptions = function DSC_setAddonOptions(aId, aOptions) { + let promises = []; + + for (let process of processes.values()) { + promises.push(process.debuggerServer.setAddonOptions(aId, aOptions)); + } + + return promise.all(promises); +}; + +BrowserToolboxProcess.prototype = { + /** + * Initializes the debugger server. + */ + _initServer: function () { + if (this.debuggerServer) { + 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 = new DevToolsLoader(); + this.loader.invisibleToDebugger = true; + let { DebuggerServer } = this.loader.require("devtools/server/main"); + this.debuggerServer = DebuggerServer; + dumpn("Created a separate loader instance for the DebuggerServer."); + + // Forward interesting events. + this.debuggerServer.on("connectionchange", this.emit); + + this.debuggerServer.init(); + this.debuggerServer.addBrowserActors(); + this.debuggerServer.allowChromeProcess = true; + dumpn("initialized and added the browser actors for the DebuggerServer."); + + let chromeDebuggingPort = + Services.prefs.getIntPref("devtools.debugger.chrome-debugging-port"); + let chromeDebuggingWebSocket = + Services.prefs.getBoolPref("devtools.debugger.chrome-debugging-websocket"); + let listener = this.debuggerServer.createListener(); + listener.portOrPath = chromeDebuggingPort; + listener.webSocket = chromeDebuggingWebSocket; + listener.open(); + + dumpn("Finished initializing the chrome toolbox server."); + dumpn("Started listening on port: " + chromeDebuggingPort); + }, + + /** + * Initializes a profile for the remote debugger process. + */ + _initProfile: function () { + dumpn("Initializing the chrome toolbox user profile."); + + let debuggingProfileDir = Services.dirsvc.get("ProfLD", Ci.nsIFile); + debuggingProfileDir.append(CHROME_DEBUGGER_PROFILE_NAME); + try { + debuggingProfileDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + } catch (ex) { + // Don't re-copy over the prefs again if this profile already exists + if (ex.result === Cr.NS_ERROR_FILE_ALREADY_EXISTS) { + this._dbgProfilePath = debuggingProfileDir.path; + } 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... + let prefsFile = debuggingProfileDir.clone(); + prefsFile.append("prefs.js"); + // ... 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. + */ + _create: function () { + dumpn("Initializing chrome debugging process."); + let process = this._dbgProcess = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess); + process.init(Services.dirsvc.get("XREExeF", Ci.nsIFile)); + + let xulURI = DBG_XUL; + + if (this._options.addonID) { + xulURI += "?addonID=" + this._options.addonID; + } + + dumpn("Running chrome debugging process."); + let args = ["-no-remote", "-foreground", "-profile", this._dbgProfilePath, "-chrome", xulURI]; + + // 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 (!Services.appinfo.isOfficial) { + args.push("-purgecaches"); + } + + // Disable safe mode for the new process in case this was opened via the + // keyboard shortcut. + let nsIEnvironment = Components.classes["@mozilla.org/process/environment;1"].getService(Components.interfaces.nsIEnvironment); + let originalValue = nsIEnvironment.get("MOZ_DISABLE_SAFE_MODE_KEY"); + nsIEnvironment.set("MOZ_DISABLE_SAFE_MODE_KEY", "1"); + + process.runwAsync(args, args.length, { observe: () => this.close() }); + + // Now that the process has started, it's safe to reset the env variable. + nsIEnvironment.set("MOZ_DISABLE_SAFE_MODE_KEY", originalValue); + + this._telemetry.toolOpened("jsbrowserdebugger"); + + dumpn("Chrome toolbox is now running..."); + this.emit("run", this); + }, + + /** + * Closes the remote debugging server and kills the toolbox process. + */ + close: function () { + if (this.closed) { + return; + } + + dumpn("Cleaning up the chrome debugging process."); + Services.obs.removeObserver(this.close, "quit-application"); + + if (this._dbgProcess.isRunning) { + this._dbgProcess.kill(); + } + + this._telemetry.toolClosed("jsbrowserdebugger"); + if (this.debuggerServer) { + this.debuggerServer.off("connectionchange", this.emit); + this.debuggerServer.destroy(); + this.debuggerServer = null; + } + + dumpn("Chrome toolbox is now closed..."); + this.closed = true; + this.emit("close", this); + processes.delete(this); + + this._dbgProcess = null; + this._options = null; + if (this.loader) { + this.loader.destroy(); + } + 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"); + +Services.prefs.addObserver("devtools.debugger.log", { + observe: (...args) => wantLogging = Services.prefs.getBoolPref(args.pop()) +}, false); + +Services.obs.notifyObservers(null, "ToolboxProcessLoaded", null); diff --git a/devtools/client/framework/about-devtools-toolbox.js b/devtools/client/framework/about-devtools-toolbox.js new file mode 100644 index 000000000..0ae776e37 --- /dev/null +++ b/devtools/client/framework/about-devtools-toolbox.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Register about:devtools-toolbox which allows to open a devtools toolbox +// in a Firefox tab or a custom html iframe in browser.html + +const { Ci, Cu, Cm, components } = require("chrome"); +const registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); +const Services = require("Services"); +const { XPCOMUtils } = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {}); +const { nsIAboutModule } = Ci; + +function AboutURL() {} + +AboutURL.prototype = { + uri: Services.io.newURI("chrome://devtools/content/framework/toolbox.xul", + null, null), + classDescription: "about:devtools-toolbox", + classID: components.ID("11342911-3135-45a8-8d71-737a2b0ad469"), + contractID: "@mozilla.org/network/protocol/about;1?what=devtools-toolbox", + + QueryInterface: XPCOMUtils.generateQI([nsIAboutModule]), + + newChannel: function (aURI, aLoadInfo) { + let chan = Services.io.newChannelFromURIWithLoadInfo(this.uri, aLoadInfo); + chan.owner = Services.scriptSecurityManager.getSystemPrincipal(); + return chan; + }, + + getURIFlags: function (aURI) { + return nsIAboutModule.ALLOW_SCRIPT || nsIAboutModule.ENABLE_INDEXED_DB; + } +}; + +AboutURL.createInstance = function (outer, iid) { + if (outer) { + throw Cr.NS_ERROR_NO_AGGREGATION; + } + return new AboutURL(); +}; + +exports.register = function () { + if (registrar.isCIDRegistered(AboutURL.prototype.classID)) { + console.error("Trying to register " + AboutURL.prototype.classDescription + + " more than once."); + } else { + registrar.registerFactory(AboutURL.prototype.classID, + AboutURL.prototype.classDescription, + AboutURL.prototype.contractID, + AboutURL); + } +}; + +exports.unregister = function () { + if (registrar.isCIDRegistered(AboutURL.prototype.classID)) { + registrar.unregisterFactory(AboutURL.prototype.classID, AboutURL); + } +}; diff --git a/devtools/client/framework/attach-thread.js b/devtools/client/framework/attach-thread.js new file mode 100644 index 000000000..db445ce23 --- /dev/null +++ b/devtools/client/framework/attach-thread.js @@ -0,0 +1,115 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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/. */ + +const {Cc, Ci, Cu} = require("chrome"); +const Services = require("Services"); +const defer = require("devtools/shared/defer"); + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties"); + +function handleThreadState(toolbox, event, packet) { + // Suppress interrupted events by default because the thread is + // paused/resumed a lot for various actions. + if (event !== "paused" || packet.why.type !== "interrupted") { + // TODO: Bug 1225492, we continue emitting events on the target + // like we used to, but we should emit these only on the + // threadClient now. + toolbox.target.emit("thread-" + event); + } + + if (event === "paused") { + toolbox.highlightTool("jsdebugger"); + + if (packet.why.type === "debuggerStatement" || + packet.why.type === "breakpoint" || + packet.why.type === "exception") { + toolbox.raise(); + toolbox.selectTool("jsdebugger"); + } + } else if (event === "resumed") { + toolbox.unhighlightTool("jsdebugger"); + } +} + +function attachThread(toolbox) { + let deferred = defer(); + + let target = toolbox.target; + let { form: { chromeDebugger, actor } } = target; + + // Sourcemaps are always turned off when using the new debugger + // frontend. This is because it does sourcemapping on the + // client-side, so the server should not do it. It also does not support + // blackboxing yet. + let useSourceMaps = false; + let autoBlackBox = false; + if(!Services.prefs.getBoolPref("devtools.debugger.new-debugger-frontend")) { + useSourceMaps = Services.prefs.getBoolPref("devtools.debugger.source-maps-enabled"); + autoBlackBox = Services.prefs.getBoolPref("devtools.debugger.auto-black-box"); + } + let threadOptions = { useSourceMaps, autoBlackBox }; + + let handleResponse = (res, threadClient) => { + if (res.error) { + deferred.reject(new Error("Couldn't attach to thread: " + res.error)); + return; + } + threadClient.addListener("paused", handleThreadState.bind(null, toolbox)); + threadClient.addListener("resumed", handleThreadState.bind(null, toolbox)); + + if (!threadClient.paused) { + deferred.reject( + new Error("Thread in wrong state when starting up, should be paused") + ); + } + + // These flags need to be set here because the client sends them + // with the `resume` request. We make sure to do this before + // resuming to avoid another interrupt. We can't pass it in with + // `threadOptions` because the resume request will override them. + threadClient.pauseOnExceptions( + Services.prefs.getBoolPref("devtools.debugger.pause-on-exceptions"), + Services.prefs.getBoolPref("devtools.debugger.ignore-caught-exceptions") + ); + + threadClient.resume(res => { + if (res.error === "wrongOrder") { + const box = toolbox.getNotificationBox(); + box.appendNotification( + L10N.getStr("toolbox.resumeOrderWarning"), + "wrong-resume-order", + "", + box.PRIORITY_WARNING_HIGH + ); + } + + deferred.resolve(threadClient); + }); + }; + + if (target.isTabActor) { + // Attaching a tab, a browser process, or a WebExtensions add-on. + target.activeTab.attachThread(threadOptions, handleResponse); + } else if (target.isAddon) { + // Attaching a legacy addon. + target.client.attachAddon(actor, res => { + target.client.attachThread(res.threadActor, handleResponse); + }); + } else { + // Attaching an old browser debugger or a content process. + target.client.attachThread(chromeDebugger, handleResponse); + } + + return deferred.promise; +} + +function detachThread(threadClient) { + threadClient.removeListener("paused"); + threadClient.removeListener("resumed"); +} + +module.exports = { attachThread, detachThread }; diff --git a/devtools/client/framework/browser-menus.js b/devtools/client/framework/browser-menus.js new file mode 100644 index 000000000..3d6c4def6 --- /dev/null +++ b/devtools/client/framework/browser-menus.js @@ -0,0 +1,390 @@ +/* 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 and key shortcuts into browser UI. + * + * Menu and shortcut definitions are fetched from: + * - devtools/client/menus for top level entires + * - devtools/client/definitions for tool-specifics entries + */ + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const MENUS_L10N = new LocalizationHelper("devtools/client/locales/menus.properties"); + +loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true); +loader.lazyRequireGetter(this, "gDevToolsBrowser", "devtools/client/framework/devtools-browser", true); + +// 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:key element + * + * @param {XULDocument} doc + * The document to which keys are to be added. + * @param {String} id + * key's id, automatically prefixed with "key_". + * @param {String} shortcut + * The key shortcut value. + * @param {String} keytext + * If `shortcut` refers to a function key, refers to the localized + * string to describe a non-character shortcut. + * @param {String} modifiers + * Space separated list of modifier names. + * @param {Function} oncommand + * The function to call when the shortcut is pressed. + * + * @return XULKeyElement + */ +function createKey({ doc, id, shortcut, keytext, modifiers, oncommand }) { + let k = doc.createElement("key"); + k.id = "key_" + id; + + if (shortcut.startsWith("VK_")) { + k.setAttribute("keycode", shortcut); + if (keytext) { + k.setAttribute("keytext", keytext); + } + } else { + k.setAttribute("key", shortcut); + } + + if (modifiers) { + k.setAttribute("modifiers", modifiers); + } + + // Bug 371900: command event is fired only if "oncommand" attribute is set. + k.setAttribute("oncommand", ";"); + k.addEventListener("command", oncommand); + + return k; +} + +/** + * Create a xul:menuitem element + * + * @param {XULDocument} doc + * The document to which keys 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. + * + * @return XULMenuItemElement + */ +function createMenuItem({ doc, id, label, accesskey, isCheckbox }) { + let menuitem = doc.createElement("menuitem"); + menuitem.id = id; + menuitem.setAttribute("label", label); + if (accesskey) { + menuitem.setAttribute("accesskey", accesskey); + } + if (isCheckbox) { + menuitem.setAttribute("type", "checkbox"); + menuitem.setAttribute("autocheck", "false"); + } + return menuitem; +} + +/** + * Add a <key> to <keyset id="devtoolsKeyset">. + * Appending a <key> element is not always enough. The <keyset> needs + * to be detached and reattached to make sure the <key> is taken into + * account (see bug 832984). + * + * @param {XULDocument} doc + * The document to which keys are to be added + * @param {XULElement} or {DocumentFragment} keys + * Keys to add + */ +function attachKeybindingsToBrowser(doc, keys) { + let devtoolsKeyset = doc.getElementById("devtoolsKeyset"); + + if (!devtoolsKeyset) { + devtoolsKeyset = doc.createElement("keyset"); + devtoolsKeyset.setAttribute("id", "devtoolsKeyset"); + } + devtoolsKeyset.appendChild(keys); + let mainKeyset = doc.getElementById("mainKeyset"); + mainKeyset.parentNode.insertBefore(devtoolsKeyset, mainKeyset); +} + +/** + * Add a menu entry for a tool definition + * + * @param {Object} toolDefinition + * Tool definition of the tool to add a menu entry. + * @param {XULDocument} doc + * The document to which the tool menu item is to be added. + */ +function createToolMenuElements(toolDefinition, doc) { + let id = toolDefinition.id; + let menuId = "menuitem_" + id; + + // Prevent multiple entries for the same tool. + if (doc.getElementById(menuId)) { + return; + } + + let oncommand = function (id, event) { + let window = event.target.ownerDocument.defaultView; + gDevToolsBrowser.selectToolCommand(window.gBrowser, id); + }.bind(null, id); + + let key = null; + if (toolDefinition.key) { + key = createKey({ + doc, + id, + shortcut: toolDefinition.key, + modifiers: toolDefinition.modifiers, + oncommand: oncommand + }); + } + + let menuitem = createMenuItem({ + doc, + id: "menuitem_" + id, + label: toolDefinition.menuLabel || toolDefinition.label, + accesskey: toolDefinition.accesskey + }); + if (key) { + // Refer to the key in order to display the key shortcut at menu ends + menuitem.setAttribute("key", key.id); + } + menuitem.addEventListener("command", oncommand); + + return { + key, + menuitem + }; +} + +/** + * Create xul menuitem, key elements for a given tool. + * And then insert them into browser DOM. + * + * @param {XULDocument} 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) { + let { key, menuitem } = createToolMenuElements(toolDefinition, doc); + + if (key) { + attachKeybindingsToBrowser(doc, key); + } + + let ref; + if (prevDef) { + let menuitem = doc.getElementById("menuitem_" + prevDef.id); + ref = menuitem && menuitem.nextSibling ? menuitem.nextSibling : null; + } else { + ref = doc.getElementById("menu_devtools_separator"); + } + + 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 {XULDocument} doc + * The document to which the tool menu item is to be removed from + */ +function removeToolFromMenu(toolId, doc) { + let key = doc.getElementById("key_" + toolId); + if (key) { + key.remove(); + } + + let menuitem = doc.getElementById("menuitem_" + toolId); + if (menuitem) { + menuitem.remove(); + } +} +exports.removeToolFromMenu = removeToolFromMenu; + +/** + * Add all tools to the developer tools menu of a window. + * + * @param {XULDocument} doc + * The document to which the tool items are to be added. + */ +function addAllToolsToMenu(doc) { + let fragKeys = doc.createDocumentFragment(); + let fragMenuItems = doc.createDocumentFragment(); + + for (let toolDefinition of gDevTools.getToolDefinitionArray()) { + if (!toolDefinition.inMenu) { + continue; + } + + let elements = createToolMenuElements(toolDefinition, doc); + + if (!elements) { + continue; + } + + if (elements.key) { + fragKeys.appendChild(elements.key); + } + fragMenuItems.appendChild(elements.menuitem); + } + + attachKeybindingsToBrowser(doc, fragKeys); + + let mps = doc.getElementById("menu_devtools_separator"); + if (mps) { + mps.parentNode.insertBefore(fragMenuItems, mps); + } +} + +/** + * Add global menus and shortcuts that are not panel specific. + * + * @param {XULDocument} doc + * The document to which keys and menus are to be added. + */ +function addTopLevelItems(doc) { + let keys = doc.createDocumentFragment(); + let menuItems = doc.createDocumentFragment(); + + let { menuitems } = require("../menus"); + for (let item of menuitems) { + if (item.separator) { + let separator = doc.createElement("menuseparator"); + separator.id = item.id; + menuItems.appendChild(separator); + } else { + let { id, l10nKey } = item; + + // Create a <menuitem> + let menuitem = createMenuItem({ + doc, + id, + label: l10n(l10nKey + ".label"), + accesskey: l10n(l10nKey + ".accesskey"), + isCheckbox: item.checkbox + }); + menuitem.addEventListener("command", item.oncommand); + menuItems.appendChild(menuitem); + + if (item.key && l10nKey) { + // Create a <key> + let shortcut = l10n(l10nKey + ".key"); + let key = createKey({ + doc, + id: item.key.id, + shortcut: shortcut, + keytext: shortcut.startsWith("VK_") ? l10n(l10nKey + ".keytext") : null, + modifiers: item.key.modifiers, + oncommand: item.oncommand + }); + // Refer to the key in order to display the key shortcut at menu ends + menuitem.setAttribute("key", key.id); + keys.appendChild(key); + } + if (item.additionalKeys) { + // Create additional <key> + for (let key of item.additionalKeys) { + let shortcut = l10n(key.l10nKey + ".key"); + let node = createKey({ + doc, + id: key.id, + shortcut: shortcut, + keytext: shortcut.startsWith("VK_") ? l10n(key.l10nKey + ".keytext") : null, + modifiers: key.modifiers, + oncommand: item.oncommand + }); + keys.appendChild(node); + } + } + } + } + + // Cache all nodes before insertion to be able to remove them on unload + let nodes = []; + for (let node of keys.children) { + nodes.push(node); + } + for (let node of menuItems.children) { + nodes.push(node); + } + FragmentsCache.set(doc, nodes); + + attachKeybindingsToBrowser(doc, keys); + + let menu = doc.getElementById("menuWebDeveloperPopup"); + menu.appendChild(menuItems); + + // There is still "Page Source" menuitem hardcoded into browser.xul. Instead + // of manually inserting everything around it, move it to the expected + // position. + let pageSource = doc.getElementById("menu_pageSource"); + let endSeparator = doc.getElementById("devToolsEndSeparator"); + menu.insertBefore(pageSource, endSeparator); +} + +/** + * Remove global menus and shortcuts that are not panel specific. + * + * @param {XULDocument} doc + * The document to which keys and menus are to be added. + */ +function removeTopLevelItems(doc) { + let nodes = FragmentsCache.get(doc); + if (!nodes) { + return; + } + FragmentsCache.delete(doc); + for (let node of nodes) { + node.remove(); + } +} + +/** + * Add menus and shortcuts to a browser document + * + * @param {XULDocument} doc + * The document to which keys and menus are to be added. + */ +exports.addMenus = function (doc) { + addTopLevelItems(doc); + + addAllToolsToMenu(doc); +}; + +/** + * Remove menus and shortcuts from a browser document + * + * @param {XULDocument} doc + * The document to which keys and 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/connect/connect.css b/devtools/client/framework/connect/connect.css new file mode 100644 index 000000000..23959b93b --- /dev/null +++ b/devtools/client/framework/connect/connect.css @@ -0,0 +1,112 @@ +:root { + font: caption; +} + +html { + background-color: #111; +} + +body { + font-family: Arial, sans-serif; + color: white; + max-width: 600px; + margin: 30px auto 0; + box-shadow: 0 2px 3px black; + background-color: #3C3E40; +} + +h1 { + margin: 0; + padding: 20px; + background-color: rgba(0,0,0,0.12); + background-image: radial-gradient(ellipse farthest-corner at center top , rgb(159, 223, 255), rgba(101, 203, 255, 0.3)), radial-gradient(ellipse farthest-side at center top , rgba(101, 203, 255, 0.4), rgba(101, 203, 255, 0)); + background-size: 100% 2px, 100% 5px; + background-repeat: no-repeat; + border-bottom: 1px solid rgba(0,0,0,0.1); +} + +form { + display: inline-block; +} + +label { + display: block; + margin: 10px; +} + +label > span { + display: inline-block; + min-width: 150px; + text-align: right; + margin-right: 10px; +} + +#submit { + float: right; +} + +input:invalid { + box-shadow: 0 0 2px 2px #F06; +} + +section { + min-height: 160px; + margin: 60px 20px; + display: none; /* By default, hidden */ +} + +.error-message { + color: red; +} + +.error-message:not(.active) { + display: none; +} + +body:not(.actors-mode):not(.connecting) > #connection-form { + display: block; +} + +body.actors-mode > #actors-list { + display: block; +} + +body.connecting > #connecting { + display: block; +} + +#connecting { + text-align: center; +} + +#connecting > p > img { + vertical-align: top; +} + +.actors { + padding-left: 0; +} + +.actors > a { + display: block; + margin: 5px; + padding: 5px; + color: white; +} + +.remote-process { + font-style: italic; + opacity: 0.8; +} + +footer { + padding: 10px; + background-color: rgba(0,0,0,0.12); + border-top: 1px solid rgba(0,0,0,0.1); + font-size: small; +} + +footer > a, +footer > a:visited { + color: white; +} diff --git a/devtools/client/framework/connect/connect.js b/devtools/client/framework/connect/connect.js new file mode 100644 index 000000000..d713231f9 --- /dev/null +++ b/devtools/client/framework/connect/connect.js @@ -0,0 +1,236 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 Cu = Components.utils; +var {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); +var Services = require("Services"); +var {gDevTools} = require("devtools/client/framework/devtools"); +var {TargetFactory} = require("devtools/client/framework/target"); +var {Toolbox} = require("devtools/client/framework/toolbox"); +var {DebuggerClient} = require("devtools/shared/client/main"); +var {Task} = require("devtools/shared/task"); +var {LocalizationHelper} = require("devtools/shared/l10n"); +var L10N = new LocalizationHelper("devtools/client/locales/connection-screen.properties"); + +var gClient; +var gConnectionTimeout; + +/** + * Once DOM is ready, we prefil the host/port inputs with + * pref-stored values. + */ +window.addEventListener("DOMContentLoaded", function onDOMReady() { + window.removeEventListener("DOMContentLoaded", onDOMReady, true); + let host = Services.prefs.getCharPref("devtools.debugger.remote-host"); + let port = Services.prefs.getIntPref("devtools.debugger.remote-port"); + + if (host) { + document.getElementById("host").value = host; + } + + if (port) { + document.getElementById("port").value = port; + } + + let form = document.querySelector("#connection-form form"); + form.addEventListener("submit", function () { + window.submit().catch(e => { + console.error(e); + // Bug 921850: catch rare exception from DebuggerClient.socketConnect + showError("unexpected"); + }); + }); +}, true); + +/** + * Called when the "connect" button is clicked. + */ +var submit = Task.async(function* () { + // Show the "connecting" screen + document.body.classList.add("connecting"); + + let host = document.getElementById("host").value; + let port = document.getElementById("port").value; + + // Save the host/port values + try { + Services.prefs.setCharPref("devtools.debugger.remote-host", host); + Services.prefs.setIntPref("devtools.debugger.remote-port", port); + } catch (e) { + // Fails in e10s mode, but not a critical feature. + } + + // Initiate the connection + let transport = yield DebuggerClient.socketConnect({ host, port }); + gClient = new DebuggerClient(transport); + let delay = Services.prefs.getIntPref("devtools.debugger.remote-timeout"); + gConnectionTimeout = setTimeout(handleConnectionTimeout, delay); + let response = yield gClient.connect(); + yield onConnectionReady(...response); +}); + +/** + * Connection is ready. List actors and build buttons. + */ +var onConnectionReady = Task.async(function* ([aType, aTraits]) { + clearTimeout(gConnectionTimeout); + + let response = yield gClient.listAddons(); + + let parent = document.getElementById("addonActors"); + if (!response.error && response.addons.length > 0) { + // Add one entry for each add-on. + for (let addon of response.addons) { + if (!addon.debuggable) { + continue; + } + buildAddonLink(addon, parent); + } + } + else { + // Hide the section when there are no add-ons + parent.previousElementSibling.remove(); + parent.remove(); + } + + response = yield gClient.listTabs(); + + parent = document.getElementById("tabActors"); + + // Add Global Process debugging... + let globals = Cu.cloneInto(response, {}); + delete globals.tabs; + delete globals.selected; + // ...only if there are appropriate actors (a 'from' property will always + // be there). + + // Add one entry for each open tab. + for (let i = 0; i < response.tabs.length; i++) { + buildTabLink(response.tabs[i], parent, i == response.selected); + } + + let gParent = document.getElementById("globalActors"); + + // Build the Remote Process button + // If Fx<39, tab actors were used to be exposed on RootActor + // but in Fx>=39, chrome is debuggable via getProcess() and ChromeActor + if (globals.consoleActor || gClient.mainRoot.traits.allowChromeProcess) { + let a = document.createElement("a"); + a.onclick = function () { + if (gClient.mainRoot.traits.allowChromeProcess) { + gClient.getProcess() + .then(aResponse => { + openToolbox(aResponse.form, true); + }); + } else if (globals.consoleActor) { + openToolbox(globals, true, "webconsole", false); + } + }; + a.title = a.textContent = L10N.getStr("mainProcess"); + a.className = "remote-process"; + a.href = "#"; + gParent.appendChild(a); + } + // Move the selected tab on top + let selectedLink = parent.querySelector("a.selected"); + if (selectedLink) { + parent.insertBefore(selectedLink, parent.firstChild); + } + + document.body.classList.remove("connecting"); + document.body.classList.add("actors-mode"); + + // Ensure the first link is focused + let firstLink = parent.querySelector("a:first-of-type"); + if (firstLink) { + firstLink.focus(); + } +}); + +/** + * Build one button for an add-on actor. + */ +function buildAddonLink(addon, parent) { + let a = document.createElement("a"); + a.onclick = function () { + openToolbox(addon, true, "jsdebugger", false); + }; + + a.textContent = addon.name; + a.title = addon.id; + a.href = "#"; + + parent.appendChild(a); +} + +/** + * Build one button for a tab actor. + */ +function buildTabLink(tab, parent, selected) { + let a = document.createElement("a"); + a.onclick = function () { + openToolbox(tab); + }; + + a.textContent = tab.title; + a.title = tab.url; + if (!a.textContent) { + a.textContent = tab.url; + } + a.href = "#"; + + if (selected) { + a.classList.add("selected"); + } + + parent.appendChild(a); +} + +/** + * An error occured. Let's show it and return to the first screen. + */ +function showError(type) { + document.body.className = "error"; + let activeError = document.querySelector(".error-message.active"); + if (activeError) { + activeError.classList.remove("active"); + } + activeError = document.querySelector(".error-" + type); + if (activeError) { + activeError.classList.add("active"); + } +} + +/** + * Connection timeout. + */ +function handleConnectionTimeout() { + showError("timeout"); +} + +/** + * The user clicked on one of the buttons. + * Opens the toolbox. + */ +function openToolbox(form, chrome = false, tool = "webconsole", isTabActor) { + let options = { + form: form, + client: gClient, + chrome: chrome, + isTabActor: isTabActor + }; + TargetFactory.forRemoteTab(options).then((target) => { + let hostType = Toolbox.HostType.WINDOW; + gDevTools.showToolbox(target, tool, hostType).then((toolbox) => { + toolbox.once("destroyed", function () { + gClient.close(); + }); + }, console.error.bind(console)); + window.close(); + }, console.error.bind(console)); +} diff --git a/devtools/client/framework/connect/connect.xhtml b/devtools/client/framework/connect/connect.xhtml new file mode 100644 index 000000000..e8f8818f6 --- /dev/null +++ b/devtools/client/framework/connect/connect.xhtml @@ -0,0 +1,52 @@ +<?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 html [ +<!ENTITY % connectionDTD SYSTEM "chrome://devtools/locale/connection-screen.dtd" > + %connectionDTD; +]> + +<html xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <head> + <title>&title;</title> + <link rel="stylesheet" href="chrome://devtools/skin/dark-theme.css" type="text/css"/> + <link rel="stylesheet" href="chrome://devtools/content/framework/connect/connect.css" type="text/css"/> + <script type="application/javascript;version=1.8" src="connect.js"></script> + </head> + <body> + <h1>&header;</h1> + <section id="connection-form"> + <form validate="validate" action="#"> + <label> + <span>&host;</span> + <input required="required" class="devtools-textinput" id="host" type="text"></input> + </label> + <label> + <span>&port;</span> + <input required="required" class="devtools-textinput" id="port" type="number" pattern="\d+"></input> + </label> + <label> + <input class="devtools-toolbarbutton" id="submit" standalone="true" type="submit" value="&connect;"></input> + </label> + </form> + <p class="error-message error-timeout">&errorTimeout;</p> + <p class="error-message error-refused">&errorRefused;</p> + <p class="error-message error-unexpected">&errorUnexpected;</p> + </section> + <section id="actors-list"> + <p>&availableTabs;</p> + <ul class="actors" id="tabActors"></ul> + <p>&availableAddons;</p> + <ul class="actors" id="addonActors"></ul> + <p>&availableProcesses;</p> + <ul class="actors" id="globalActors"></ul> + </section> + <section id="connecting"> + <p class="devtools-throbber">&connecting;</p> + </section> + <footer>&remoteHelp;<a target='_' href='https://developer.mozilla.org/docs/Tools/Remote_Debugging'>&remoteDocumentation;</a>&remoteHelpSuffix;</footer> + </body> +</html> diff --git a/devtools/client/framework/dev-edition-promo/dev-edition-logo.png b/devtools/client/framework/dev-edition-promo/dev-edition-logo.png Binary files differnew file mode 100644 index 000000000..4b90768d2 --- /dev/null +++ b/devtools/client/framework/dev-edition-promo/dev-edition-logo.png diff --git a/devtools/client/framework/dev-edition-promo/dev-edition-promo.css b/devtools/client/framework/dev-edition-promo/dev-edition-promo.css new file mode 100644 index 000000000..01489fd47 --- /dev/null +++ b/devtools/client/framework/dev-edition-promo/dev-edition-promo.css @@ -0,0 +1,94 @@ +/* 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/. */ + +window { + -moz-appearance: none; + background-color: transparent; +} + +#doorhanger-container { + width: 450px; +} + +#top-panel { + padding: 20px; + background: #343c45; /* toolbars */ + color: #8fa1b2; /* body text */ +/* + * Sloppy preprocessing since UNIX_BUT_NOT_MAC is only defined + * in `browser/app/profile/firefox.js`, which this file cannot + * depend on. Must style font-size to target linux. + */ +%ifdef XP_UNIX +%ifndef XP_MACOSX + font-size: 13px; +%else + font-size: 15px; +%endif +%else + font-size: 15px; +%endif + line-height: 19px; + min-height: 100px; +} + +#top-panel h1 { + font-weight: bold; + font-family: Open Sans, sans-serif; + font-size: 1.1em; +} + +#top-panel p { + font-family: Open Sans, sans-serif; + font-size: 0.9em; + width: 300px; + display: block; + margin: 5px 0px 0px 0px; +} + +#icon { + background-image: url("chrome://devtools/content/framework/dev-edition-promo/dev-edition-logo.png"); + background-size: 64px 64px; + background-repeat: no-repeat; + width: 64px; + height: 64px; + margin-right: 20px; +} + +#lower-panel { + padding: 20px; + background-color: #252c33; /* tab toolbars */ + min-height: 75px; + border-top: 1px solid #292e33; /* text high contrast (light) */ +} + +#button-container { + margin: auto 20px; +} + +#button-container button { + font: message-box !important; + font-size: 16px !important; + cursor: pointer; + width: 125px; + opacity: 1; + position: static; + -moz-appearance: none; + border-radius: 5px; + height: 30px; + width: 450px; + /* Override embossed borders on Windows/Linux */ + border: none; +} + +#close { + background-color: transparent; + color: #8fa1b2; /* body text */ +} + +#go { + margin-left: 100px; + background-color: #70bf53; /* green */ + color: #f5f7fa; /* selection text color */ +} diff --git a/devtools/client/framework/dev-edition-promo/dev-edition-promo.xul b/devtools/client/framework/dev-edition-promo/dev-edition-promo.xul new file mode 100644 index 000000000..ca2515ab0 --- /dev/null +++ b/devtools/client/framework/dev-edition-promo/dev-edition-promo.xul @@ -0,0 +1,36 @@ +<?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 [ +<!ENTITY % toolboxDTD SYSTEM "chrome://devtools/locale/toolbox.dtd" > + %toolboxDTD; +]> +<?xml-stylesheet href="chrome://global/skin/" type="text/css"?> +<?xml-stylesheet rel="stylesheet" href="chrome://devtools/content/framework/dev-edition-promo/dev-edition-promo.css" type="text/css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" id="dev-edition-promo"> + <vbox id="doorhanger-container"> + <hbox flex="1" id="top-panel"> + <image id="icon" /> + <vbox id="info"> + <h1>Using Developer Tools in your browser?</h1> + <p>Download Firefox Developer Edition, our first browser made just for you.</p> + </vbox> + </hbox> + <hbox id="lower-panel" flex="1"> + <hbox id="button-container" flex="1"> + <button id="close" + flex="1" + standalone="true" + label="No thanks"> + </button> + <button id="go" + flex="1" + standalone="true" + label="Learn more »"> + </button> + </hbox> + </hbox> + </vbox> +</window> diff --git a/devtools/client/framework/devtools-browser.js b/devtools/client/framework/devtools-browser.js new file mode 100644 index 000000000..b9f4d92ba --- /dev/null +++ b/devtools/client/framework/devtools-browser.js @@ -0,0 +1,758 @@ +/* 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 {Cc, Ci, Cu} = require("chrome"); +const Services = require("Services"); +const promise = require("promise"); +const defer = require("devtools/shared/defer"); +const Telemetry = require("devtools/client/shared/telemetry"); +const { gDevTools } = require("./devtools"); +const { when: unload } = require("sdk/system/unload"); + +// Load target and toolbox lazily as they need gDevTools to be fully initialized +loader.lazyRequireGetter(this, "TargetFactory", "devtools/client/framework/target", true); +loader.lazyRequireGetter(this, "Toolbox", "devtools/client/framework/toolbox", true); +loader.lazyRequireGetter(this, "DebuggerServer", "devtools/server/main", true); +loader.lazyRequireGetter(this, "DebuggerClient", "devtools/shared/client/main", true); +loader.lazyRequireGetter(this, "BrowserMenus", "devtools/client/framework/browser-menus"); + +loader.lazyImporter(this, "CustomizableUI", "resource:///modules/CustomizableUI.jsm"); +loader.lazyImporter(this, "AppConstants", "resource://gre/modules/AppConstants.jsm"); + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties"); + +const TABS_OPEN_PEAK_HISTOGRAM = "DEVTOOLS_TABS_OPEN_PEAK_LINEAR"; +const TABS_OPEN_AVG_HISTOGRAM = "DEVTOOLS_TABS_OPEN_AVERAGE_LINEAR"; +const TABS_PINNED_PEAK_HISTOGRAM = "DEVTOOLS_TABS_PINNED_PEAK_LINEAR"; +const TABS_PINNED_AVG_HISTOGRAM = "DEVTOOLS_TABS_PINNED_AVERAGE_LINEAR"; + +/** + * 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(), + + _telemetry: new Telemetry(), + + _tabStats: { + peakOpen: 0, + peakPinned: 0, + histOpen: [], + histPinned: [] + }, + + /** + * 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: function (gBrowser) { + let target = TargetFactory.forTab(gBrowser.selectedTab); + let toolbox = gDevTools.getToolbox(target); + + // If a toolbox exists, using toggle from the Main window : + // - should close a docked toolbox + // - should focus a windowed toolbox + let isDocked = toolbox && toolbox.hostType != Toolbox.HostType.WINDOW; + isDocked ? gDevTools.closeToolbox(target) : gDevTools.showToolbox(target); + }, + + /** + * 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: function (win) { + let doc = win.document; + + function toggleMenuItem(id, isEnabled) { + let cmd = doc.getElementById(id); + if (isEnabled) { + cmd.removeAttribute("disabled"); + cmd.removeAttribute("hidden"); + } else { + cmd.setAttribute("disabled", "true"); + cmd.setAttribute("hidden", "true"); + } + } + + // Enable developer toolbar? + let devToolbarEnabled = Services.prefs.getBoolPref("devtools.toolbar.enabled"); + toggleMenuItem("menu_devToolbar", devToolbarEnabled); + let focusEl = doc.getElementById("menu_devToolbar"); + if (devToolbarEnabled) { + focusEl.removeAttribute("disabled"); + } else { + focusEl.setAttribute("disabled", "true"); + } + if (devToolbarEnabled && Services.prefs.getBoolPref("devtools.toolbar.visible")) { + win.DeveloperToolbar.show(false).catch(console.error); + } + + // Enable WebIDE? + let webIDEEnabled = Services.prefs.getBoolPref("devtools.webide.enabled"); + toggleMenuItem("menu_webide", webIDEEnabled); + + let showWebIDEWidget = Services.prefs.getBoolPref("devtools.webide.widget.enabled"); + if (webIDEEnabled && showWebIDEWidget) { + gDevToolsBrowser.installWebIDEWidget(); + } else { + gDevToolsBrowser.uninstallWebIDEWidget(); + } + + // Enable Browser Toolbox? + let chromeEnabled = Services.prefs.getBoolPref("devtools.chrome.enabled"); + let devtoolsRemoteEnabled = Services.prefs.getBoolPref("devtools.debugger.remote-enabled"); + let remoteEnabled = chromeEnabled && devtoolsRemoteEnabled; + toggleMenuItem("menu_browserToolbox", remoteEnabled); + toggleMenuItem("menu_browserContentToolbox", remoteEnabled && win.gMultiProcessBrowser); + + // Enable DevTools connection screen, if the preference allows this. + toggleMenuItem("menu_devtools_connect", devtoolsRemoteEnabled); + }, + + observe: function (subject, topic, prefName) { + switch (topic) { + case "browser-delayed-startup-finished": + this._registerBrowserWindow(subject); + break; + case "nsPref:changed": + if (prefName.endsWith("enabled")) { + for (let win of this._trackedBrowserWindows) { + this.updateCommandAvailability(win); + } + } + break; + } + }, + + _prefObserverRegistered: false, + + ensurePrefObserver: function () { + if (!this._prefObserverRegistered) { + this._prefObserverRegistered = true; + Services.prefs.addObserver("devtools.", this, false); + } + }, + + /** + * This function is for the benefit of Tools:{toolId} commands, + * triggered from the WebDeveloper menu and keyboard shortcuts. + * + * selectToolCommand's behavior: + * - 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 + selectToolCommand: function (gBrowser, toolId) { + let target = TargetFactory.forTab(gBrowser.selectedTab); + let toolbox = gDevTools.getToolbox(target); + let toolDefinition = gDevTools.getToolDefinition(toolId); + + if (toolbox && + (toolbox.currentToolId == toolId || + (toolId == "webconsole" && toolbox.splitConsole))) + { + toolbox.fireCustomKey(toolId); + + if (toolDefinition.preventClosingOnKey || toolbox.hostType == Toolbox.HostType.WINDOW) { + toolbox.raise(); + } else { + gDevTools.closeToolbox(target); + } + gDevTools.emit("select-tool-command", toolId); + } else { + gDevTools.showToolbox(target, toolId).then(() => { + let target = TargetFactory.forTab(gBrowser.selectedTab); + let toolbox = gDevTools.getToolbox(target); + + toolbox.fireCustomKey(toolId); + gDevTools.emit("select-tool-command", toolId); + }); + } + }, + + /** + * Open a tab on "about:debugging", optionally pre-select a given tab. + */ + // Used by browser-sets.inc, command + openAboutDebugging: function (gBrowser, hash) { + let url = "about:debugging" + (hash ? "#" + hash : ""); + gBrowser.selectedTab = gBrowser.addTab(url); + }, + + /** + * Open a tab to allow connects to a remote browser + */ + // Used by browser-sets.inc, command + openConnectScreen: function (gBrowser) { + gBrowser.selectedTab = gBrowser.addTab("chrome://devtools/content/framework/connect/connect.xhtml"); + }, + + /** + * Open WebIDE + */ + // Used by browser-sets.inc, command + // itself, webide widget + openWebIDE: function () { + let win = Services.wm.getMostRecentWindow("devtools:webide"); + if (win) { + win.focus(); + } else { + Services.ww.openWindow(null, "chrome://webide/content/", "webide", "chrome,centerscreen,resizable", null); + } + }, + + _getContentProcessTarget: function (processId) { + // Create a DebuggerServer in order to connect locally to it + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + DebuggerServer.allowChromeProcess = true; + + let transport = DebuggerServer.connectPipe(); + let client = new DebuggerClient(transport); + + let deferred = defer(); + client.connect().then(() => { + client.getProcess(processId) + .then(response => { + let options = { + form: response.form, + client: client, + chrome: true, + isTabActor: false + }; + return TargetFactory.forRemoteTab(options); + }) + .then(target => { + // Ensure closing the connection in order to cleanup + // the debugger client and also the server created in the + // content process + target.on("close", () => { + client.close(); + }); + deferred.resolve(target); + }); + }); + + return deferred.promise; + }, + + // Used by menus.js + openContentProcessToolbox: function (gBrowser) { + let { childCount } = Services.ppmm; + // Get the process message manager for the current tab + let mm = gBrowser.selectedBrowser.messageManager.processMessageManager; + let processId = null; + for (let i = 1; i < childCount; i++) { + let child = Services.ppmm.getChildAt(i); + if (child == mm) { + processId = i; + break; + } + } + if (processId) { + this._getContentProcessTarget(processId) + .then(target => { + // Display a new toolbox, in a new window, with debugger by default + return gDevTools.showToolbox(target, "jsdebugger", + Toolbox.HostType.WINDOW); + }); + } else { + let msg = L10N.getStr("toolbox.noContentProcessForTab.message"); + Services.prompt.alert(null, "", msg); + } + }, + + /** + * Install Developer widget + */ + installDeveloperWidget: function () { + let id = "developer-button"; + let widget = CustomizableUI.getWidget(id); + if (widget && widget.provider == CustomizableUI.PROVIDER_API) { + return; + } + CustomizableUI.createWidget({ + id: id, + type: "view", + viewId: "PanelUI-developer", + shortcutId: "key_devToolboxMenuItem", + tooltiptext: "developer-button.tooltiptext2", + defaultArea: AppConstants.MOZ_DEV_EDITION ? + CustomizableUI.AREA_NAVBAR : + CustomizableUI.AREA_PANEL, + onViewShowing: function (aEvent) { + // Populate the subview with whatever menuitems are in the developer + // menu. We skip menu elements, because the menu panel has no way + // of dealing with those right now. + let doc = aEvent.target.ownerDocument; + let win = doc.defaultView; + + let menu = doc.getElementById("menuWebDeveloperPopup"); + + let itemsToDisplay = [...menu.children]; + // Hardcode the addition of the "work offline" menuitem at the bottom: + itemsToDisplay.push({localName: "menuseparator", getAttribute: () => {}}); + itemsToDisplay.push(doc.getElementById("goOfflineMenuitem")); + + let developerItems = doc.getElementById("PanelUI-developerItems"); + // Import private helpers from CustomizableWidgets + let { clearSubview, fillSubviewFromMenuItems } = + Cu.import("resource:///modules/CustomizableWidgets.jsm", {}); + clearSubview(developerItems); + fillSubviewFromMenuItems(itemsToDisplay, developerItems); + }, + onBeforeCreated: function (doc) { + // Bug 1223127, CUI should make this easier to do. + if (doc.getElementById("PanelUI-developerItems")) { + return; + } + let view = doc.createElement("panelview"); + view.id = "PanelUI-developerItems"; + let panel = doc.createElement("vbox"); + panel.setAttribute("class", "panel-subview-body"); + view.appendChild(panel); + doc.getElementById("PanelUI-multiView").appendChild(view); + } + }); + }, + + /** + * Install WebIDE widget + */ + // Used by itself + installWebIDEWidget: function () { + if (this.isWebIDEWidgetInstalled()) { + return; + } + + let defaultArea; + if (Services.prefs.getBoolPref("devtools.webide.widget.inNavbarByDefault")) { + defaultArea = CustomizableUI.AREA_NAVBAR; + } else { + defaultArea = CustomizableUI.AREA_PANEL; + } + + CustomizableUI.createWidget({ + id: "webide-button", + shortcutId: "key_webide", + label: "devtools-webide-button2.label", + tooltiptext: "devtools-webide-button2.tooltiptext", + defaultArea: defaultArea, + onCommand: function (aEvent) { + gDevToolsBrowser.openWebIDE(); + } + }); + }, + + isWebIDEWidgetInstalled: function () { + let widgetWrapper = CustomizableUI.getWidget("webide-button"); + return !!(widgetWrapper && widgetWrapper.provider == CustomizableUI.PROVIDER_API); + }, + + /** + * The deferred promise will be resolved by WebIDE's UI.init() + */ + isWebIDEInitialized: defer(), + + /** + * Uninstall WebIDE widget + */ + uninstallWebIDEWidget: function () { + if (this.isWebIDEWidgetInstalled()) { + CustomizableUI.removeWidgetFromArea("webide-button"); + } + CustomizableUI.destroyWidget("webide-button"); + }, + + /** + * Move WebIDE widget to the navbar + */ + // Used by webide.js + moveWebIDEWidgetInNavbar: function () { + CustomizableUI.addWidgetToArea("webide-button", CustomizableUI.AREA_NAVBAR); + }, + + /** + * Add this DevTools's presence to a browser window's document + * + * @param {XULDocument} doc + * The document to which devtools should be hooked to. + */ + _registerBrowserWindow: function (win) { + if (gDevToolsBrowser._trackedBrowserWindows.has(win)) { + return; + } + gDevToolsBrowser._trackedBrowserWindows.add(win); + + BrowserMenus.addMenus(win.document); + + // Register the Developer widget in the Hamburger menu or navbar + // only once menus are registered as it depends on it. + gDevToolsBrowser.installDeveloperWidget(); + + // Inject lazily DeveloperToolbar on the chrome window + loader.lazyGetter(win, "DeveloperToolbar", function () { + let { DeveloperToolbar } = require("devtools/client/shared/developer-toolbar"); + return new DeveloperToolbar(win); + }); + + this.updateCommandAvailability(win); + this.ensurePrefObserver(); + win.addEventListener("unload", this); + + let tabContainer = win.gBrowser.tabContainer; + tabContainer.addEventListener("TabSelect", this, false); + tabContainer.addEventListener("TabOpen", this, false); + tabContainer.addEventListener("TabClose", this, false); + tabContainer.addEventListener("TabPinned", this, false); + tabContainer.addEventListener("TabUnpinned", this, false); + }, + + /** + * Hook the JS debugger tool to the "Debug Script" button of the slow script + * dialog. + */ + setSlowScriptDebugHandler: function DT_setSlowScriptDebugHandler() { + let debugService = Cc["@mozilla.org/dom/slow-script-debug;1"] + .getService(Ci.nsISlowScriptDebug); + let tm = Cc["@mozilla.org/thread-manager;1"].getService(Ci.nsIThreadManager); + + function slowScriptDebugHandler(aTab, aCallback) { + let target = TargetFactory.forTab(aTab); + + gDevTools.showToolbox(target, "jsdebugger").then(toolbox => { + let threadClient = toolbox.getCurrentPanel().panelWin.gThreadClient; + + // Break in place, which means resuming the debuggee thread and pausing + // right before the next step happens. + switch (threadClient.state) { + case "paused": + // When the debugger is already paused. + threadClient.resumeThenPause(); + aCallback(); + break; + case "attached": + // When the debugger is already open. + threadClient.interrupt(() => { + threadClient.resumeThenPause(); + aCallback(); + }); + break; + case "resuming": + // The debugger is newly opened. + threadClient.addOneTimeListener("resumed", () => { + threadClient.interrupt(() => { + threadClient.resumeThenPause(); + aCallback(); + }); + }); + break; + default: + throw Error("invalid thread client state in slow script debug handler: " + + threadClient.state); + } + }); + } + + debugService.activationHandler = function (aWindow) { + let chromeWindow = aWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShellTreeItem) + .rootTreeItem + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow) + .QueryInterface(Ci.nsIDOMChromeWindow); + + let setupFinished = false; + slowScriptDebugHandler(chromeWindow.gBrowser.selectedTab, + () => { setupFinished = true; }); + + // Don't return from the interrupt handler until the debugger is brought + // up; no reason to continue executing the slow script. + let utils = aWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + utils.enterModalState(); + while (!setupFinished) { + tm.currentThread.processNextEvent(true); + } + utils.leaveModalState(); + }; + + debugService.remoteActivationHandler = function (aBrowser, aCallback) { + let chromeWindow = aBrowser.ownerDocument.defaultView; + let tab = chromeWindow.gBrowser.getTabForBrowser(aBrowser); + chromeWindow.gBrowser.selected = tab; + + function callback() { + aCallback.finishDebuggerStartup(); + } + + slowScriptDebugHandler(tab, callback); + }; + }, + + /** + * Unset the slow script debug handler. + */ + unsetSlowScriptDebugHandler: function DT_unsetSlowScriptDebugHandler() { + let debugService = Cc["@mozilla.org/dom/slow-script-debug;1"] + .getService(Ci.nsISlowScriptDebug); + debugService.activationHandler = undefined; + }, + + /** + * Add the menuitem for a tool to all open browser windows. + * + * @param {object} toolDefinition + * properties of the tool to add + */ + _addToolToWindows: function DT_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) {} + + // 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 + let allDefs = gDevTools.getToolDefinitionArray(); + let prevDef; + for (let def of allDefs) { + if (!def.inMenu) { + continue; + } + if (def === toolDefinition) { + break; + } + prevDef = def; + } + + for (let win of gDevToolsBrowser._trackedBrowserWindows) { + BrowserMenus.insertToolMenuElements(win.document, toolDefinition, prevDef); + } + + if (toolDefinition.id === "jsdebugger") { + gDevToolsBrowser.setSlowScriptDebugHandler(); + } + }, + + hasToolboxOpened: function (win) { + let tab = win.gBrowser.selectedTab; + for (let [target, toolbox] of gDevTools._toolboxes) { + if (target.tab == tab) { + return true; + } + } + return false; + }, + + /** + * Update the "Toggle Tools" checkbox in the developer tools menu. This is + * called when a toolbox is created or destroyed. + */ + _updateMenuCheckbox: function DT_updateMenuCheckbox() { + for (let win of gDevToolsBrowser._trackedBrowserWindows) { + + let hasToolbox = gDevToolsBrowser.hasToolboxOpened(win); + + let menu = win.document.getElementById("menu_devToolbox"); + if (hasToolbox) { + menu.setAttribute("checked", "true"); + } else { + menu.removeAttribute("checked"); + } + } + }, + + /** + * Remove the menuitem for a tool to all open browser windows. + * + * @param {string} toolId + * id of the tool to remove + */ + _removeToolFromWindows: function DT_removeToolFromWindows(toolId) { + for (let win of gDevToolsBrowser._trackedBrowserWindows) { + BrowserMenus.removeToolFromMenu(toolId, win.document); + } + + if (toolId === "jsdebugger") { + gDevToolsBrowser.unsetSlowScriptDebugHandler(); + } + }, + + /** + * 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: function (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 (let [target, toolbox] of gDevTools._toolboxes) { + if (toolbox.win.top == win) { + toolbox.destroy(); + } + } + + // Destroy the Developer toolbar if it has been accessed + let desc = Object.getOwnPropertyDescriptor(win, "DeveloperToolbar"); + if (desc && !desc.get) { + win.DeveloperToolbar.destroy(); + } + + let tabContainer = win.gBrowser.tabContainer; + tabContainer.removeEventListener("TabSelect", this, false); + tabContainer.removeEventListener("TabOpen", this, false); + tabContainer.removeEventListener("TabClose", this, false); + tabContainer.removeEventListener("TabPinned", this, false); + tabContainer.removeEventListener("TabUnpinned", this, false); + }, + + handleEvent: function (event) { + switch (event.type) { + case "TabOpen": + case "TabClose": + case "TabPinned": + case "TabUnpinned": + let open = 0; + let pinned = 0; + + for (let win of this._trackedBrowserWindows) { + let tabContainer = win.gBrowser.tabContainer; + let numPinnedTabs = win.gBrowser._numPinnedTabs || 0; + let numTabs = tabContainer.itemCount - numPinnedTabs; + + open += numTabs; + pinned += numPinnedTabs; + } + + this._tabStats.histOpen.push(open); + this._tabStats.histPinned.push(pinned); + this._tabStats.peakOpen = Math.max(open, this._tabStats.peakOpen); + this._tabStats.peakPinned = Math.max(pinned, this._tabStats.peakPinned); + break; + case "TabSelect": + gDevToolsBrowser._updateMenuCheckbox(); + break; + case "unload": + // top-level browser window unload + gDevToolsBrowser._forgetBrowserWindow(event.target.defaultView); + break; + } + }, + + _pingTelemetry: function () { + let mean = function (arr) { + if (arr.length === 0) { + return 0; + } + + let total = arr.reduce((a, b) => a + b); + return Math.ceil(total / arr.length); + }; + + let tabStats = gDevToolsBrowser._tabStats; + this._telemetry.log(TABS_OPEN_PEAK_HISTOGRAM, tabStats.peakOpen); + this._telemetry.log(TABS_OPEN_AVG_HISTOGRAM, mean(tabStats.histOpen)); + this._telemetry.log(TABS_PINNED_PEAK_HISTOGRAM, tabStats.peakPinned); + this._telemetry.log(TABS_PINNED_AVG_HISTOGRAM, mean(tabStats.histPinned)); + }, + + /** + * All browser windows have been closed, tidy up remaining objects. + */ + destroy: function () { + Services.prefs.removeObserver("devtools.", gDevToolsBrowser); + Services.obs.removeObserver(gDevToolsBrowser, "browser-delayed-startup-finished"); + Services.obs.removeObserver(gDevToolsBrowser.destroy, "quit-application"); + + gDevToolsBrowser._pingTelemetry(); + gDevToolsBrowser._telemetry = null; + + for (let win of gDevToolsBrowser._trackedBrowserWindows) { + gDevToolsBrowser._forgetBrowserWindow(win); + } + }, +}; + +// Handle all already registered tools, +gDevTools.getToolDefinitionArray() + .forEach(def => gDevToolsBrowser._addToolToWindows(def)); +// and the new ones. +gDevTools.on("tool-registered", function (ev, toolId) { + let toolDefinition = gDevTools._tools.get(toolId); + gDevToolsBrowser._addToolToWindows(toolDefinition); +}); + +gDevTools.on("tool-unregistered", function (ev, toolId) { + if (typeof toolId != "string") { + toolId = toolId.id; + } + gDevToolsBrowser._removeToolFromWindows(toolId); +}); + +gDevTools.on("toolbox-ready", gDevToolsBrowser._updateMenuCheckbox); +gDevTools.on("toolbox-destroyed", gDevToolsBrowser._updateMenuCheckbox); + +Services.obs.addObserver(gDevToolsBrowser.destroy, "quit-application", false); +Services.obs.addObserver(gDevToolsBrowser, "browser-delayed-startup-finished", false); + +// Fake end of browser window load event for all already opened windows +// that is already fully loaded. +let enumerator = Services.wm.getEnumerator(gDevTools.chromeWindowType); +while (enumerator.hasMoreElements()) { + let win = enumerator.getNext(); + if (win.gBrowserInit && win.gBrowserInit.delayedStartupFinished) { + gDevToolsBrowser._registerBrowserWindow(win); + } +} + +// Watch for module loader unload. Fires when the tools are reloaded. +unload(function () { + gDevToolsBrowser.destroy(); +}); diff --git a/devtools/client/framework/devtools.js b/devtools/client/framework/devtools.js new file mode 100644 index 000000000..90f88023b --- /dev/null +++ b/devtools/client/framework/devtools.js @@ -0,0 +1,534 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Services = require("Services"); +const promise = require("promise"); +const defer = require("devtools/shared/defer"); + +// Load gDevToolsBrowser toolbox lazily as they need gDevTools to be fully initialized +loader.lazyRequireGetter(this, "Toolbox", "devtools/client/framework/toolbox", true); +loader.lazyRequireGetter(this, "ToolboxHostManager", "devtools/client/framework/toolbox-host-manager", true); +loader.lazyRequireGetter(this, "gDevToolsBrowser", "devtools/client/framework/devtools-browser", true); + +const {defaultTools: DefaultTools, defaultThemes: DefaultThemes} = + require("devtools/client/definitions"); +const EventEmitter = require("devtools/shared/event-emitter"); +const {JsonView} = require("devtools/client/jsonview/main"); +const AboutDevTools = require("devtools/client/framework/about-devtools-toolbox"); +const {when: unload} = require("sdk/system/unload"); +const {Task} = require("devtools/shared/task"); + +const FORBIDDEN_IDS = new Set(["toolbox", ""]); +const MAX_ORDINAL = 99; + +/** + * 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. + */ +this.DevTools = function DevTools() { + this._tools = new Map(); // Map<toolId, tool> + this._themes = new Map(); // Map<themeId, theme> + this._toolboxes = new Map(); // Map<target, toolbox> + // List of toolboxes that are still in process of creation + this._creatingToolboxes = new Map(); // Map<target, toolbox Promise> + + // destroy() is an observer's handler so we need to preserve context. + this.destroy = this.destroy.bind(this); + + // JSON Viewer for 'application/json' documents. + JsonView.initialize(); + + AboutDevTools.register(); + + EventEmitter.decorate(this); + + Services.obs.addObserver(this.destroy, "quit-application", false); + + // 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(); +}; + +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 (let definition of this.getToolDefinitionArray()) { + this.unregisterTool(definition.id); + } + for (let 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) + * - invertIconForLightTheme: The icon can automatically have an inversion + * filter applied (default is false). All builtin tools are true, but + * addons may omit this to prevent unwanted changes to the `icon` + * image. filter: invert(1) is applied to the image (boolean|optional) + * - 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: function DT_registerTool(toolDefinition) { + let 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.indexOf(toolDefinition) == -1) { + 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: function DT_unregisterTool(tool, isQuitApplication) { + let toolId = null; + if (typeof tool == "string") { + toolId = tool; + tool = this._tools.get(tool); + } + else { + toolId = tool.id; + } + this._tools.delete(toolId); + + if (!isQuitApplication) { + this.emit("tool-unregistered", tool); + } + }, + + /** + * Sorting function used for sorting tools based on their ordinals. + */ + ordinalSort: function DT_ordinalSort(d1, d2) { + let o1 = (typeof d1.ordinal == "number") ? d1.ordinal : MAX_ORDINAL; + let o2 = (typeof d2.ordinal == "number") ? d2.ordinal : MAX_ORDINAL; + return o1 - o2; + }, + + getDefaultTools: function DT_getDefaultTools() { + return DefaultTools.sort(this.ordinalSort); + }, + + getAdditionalTools: function DT_getAdditionalTools() { + let tools = []; + for (let [key, value] of this._tools) { + if (DefaultTools.indexOf(value) == -1) { + 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: function DT_getToolDefinition(toolId) { + let tool = this._tools.get(toolId); + if (!tool) { + return null; + } else if (!tool.visibilityswitch) { + return tool; + } + + let enabled; + try { + enabled = Services.prefs.getBoolPref(tool.visibilityswitch); + } catch (e) { + enabled = 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: function DT_getToolDefinitionMap() { + let tools = new Map(); + + for (let [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: function DT_getToolDefinitionArray() { + let definitions = []; + + for (let [id, definition] of this._tools) { + if (this.getToolDefinition(id)) { + definitions.push(definition); + } + } + + return definitions.sort(this.ordinalSort); + }, + + /** + * 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: function DT_registerTheme(themeDefinition) { + let 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: function DT_unregisterTheme(theme) { + let themeId = null; + if (typeof theme == "string") { + themeId = theme; + theme = this._themes.get(theme); + } + else { + themeId = theme.id; + } + + let currTheme = Services.prefs.getCharPref("devtools.theme"); + + // 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 + let 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) { + Services.prefs.setCharPref("devtools.theme", "light"); + + let data = { + pref: "devtools.theme", + newValue: "light", + oldValue: currTheme + }; + + this.emit("pref-changed", data); + + 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: function DT_getThemeDefinition(themeId) { + let 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: function DT_getThemeDefinitionMap() { + let themes = new Map(); + + for (let [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: function DT_getThemeDefinitionArray() { + let definitions = []; + + for (let [id, definition] of this._themes) { + if (this.getThemeDefinition(id)) { + definitions.push(definition); + } + } + + return definitions.sort(this.ordinalSort); + }, + + /** + * Show a Toolbox for a target (either by creating a new one, or if a toolbox + * already exists for the target, by bring to the front the existing one) + * If |toolId| is specified then the displayed toolbox will have the + * specified tool selected. + * If |hostType| is specified then the toolbox will be displayed using the + * specified HostType. + * + * @param {Target} target + * The target the toolbox will debug + * @param {string} toolId + * The id of the tool to show + * @param {Toolbox.HostType} hostType + * The type of host (bottom, window, side) + * @param {object} hostOptions + * Options for host specifically + * + * @return {Toolbox} toolbox + * The toolbox that was opened + */ + showToolbox: Task.async(function* (target, toolId, hostType, hostOptions) { + let toolbox = this._toolboxes.get(target); + if (toolbox) { + + if (hostType != null && toolbox.hostType != hostType) { + yield toolbox.switchHost(hostType); + } + + if (toolId != null && toolbox.currentToolId != toolId) { + yield toolbox.selectTool(toolId); + } + + toolbox.raise(); + } else { + // As toolbox object creation is async, we have to be careful about races + // Check for possible already in process of loading toolboxes before + // actually trying to create a new one. + let promise = this._creatingToolboxes.get(target); + if (promise) { + return yield promise; + } + let toolboxPromise = this.createToolbox(target, toolId, hostType, hostOptions); + this._creatingToolboxes.set(target, toolboxPromise); + toolbox = yield toolboxPromise; + this._creatingToolboxes.delete(target); + } + return toolbox; + }), + + createToolbox: Task.async(function* (target, toolId, hostType, hostOptions) { + let manager = new ToolboxHostManager(target, hostType, hostOptions); + + let toolbox = yield manager.create(toolId); + + this._toolboxes.set(target, toolbox); + + this.emit("toolbox-created", toolbox); + + toolbox.once("destroy", () => { + this.emit("toolbox-destroy", target); + }); + + toolbox.once("destroyed", () => { + this._toolboxes.delete(target); + this.emit("toolbox-destroyed", target); + }); + + yield toolbox.open(); + this.emit("toolbox-ready", toolbox); + + return toolbox; + }), + + /** + * Return the toolbox for a given target. + * + * @param {object} target + * Target value e.g. the target that owns this toolbox + * + * @return {Toolbox} toolbox + * The toolbox that is debugging the given target + */ + getToolbox: function DT_getToolbox(target) { + return this._toolboxes.get(target); + }, + + /** + * Close the toolbox for a given target + * + * @return promise + * This promise will resolve to false if no toolbox was found + * associated to the target. true, if the toolbox was successfully + * closed. + */ + closeToolbox: Task.async(function* (target) { + let toolbox = yield this._creatingToolboxes.get(target); + if (!toolbox) { + toolbox = this._toolboxes.get(target); + } + if (!toolbox) { + return false; + } + yield toolbox.destroy(); + return true; + }), + + /** + * Called to tear down a tools provider. + */ + _teardown: function DT_teardown() { + for (let [target, toolbox] of this._toolboxes) { + toolbox.destroy(); + } + AboutDevTools.unregister(); + }, + + /** + * All browser windows have been closed, tidy up remaining objects. + */ + destroy: function () { + Services.obs.removeObserver(this.destroy, "quit-application"); + + for (let [key, tool] of this.getToolDefinitionMap()) { + this.unregisterTool(key, true); + } + + JsonView.destroy(); + + gDevTools.unregisterDefaults(); + + // Cleaning down the toolboxes: i.e. + // for (let [target, toolbox] of this._toolboxes) toolbox.destroy(); + // Is taken care of by the gDevToolsBrowser.forgetBrowserWindow + }, + + /** + * Iterator that yields each of the toolboxes. + */ + *[Symbol.iterator ]() { + for (let toolbox of this._toolboxes) { + yield toolbox; + } + } +}; + +const gDevTools = exports.gDevTools = new DevTools(); + +// Watch for module loader unload. Fires when the tools are reloaded. +unload(function () { + gDevTools._teardown(); +}); diff --git a/devtools/client/framework/gDevTools.jsm b/devtools/client/framework/gDevTools.jsm new file mode 100644 index 000000000..d825c0eaa --- /dev/null +++ b/devtools/client/framework/gDevTools.jsm @@ -0,0 +1,162 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 JSM is here to keep some compatibility with existing add-ons. + * Please now use the modules: + * - devtools/client/framework/devtools for gDevTools + * - devtools/client/framework/devtools-browser for gDevToolsBrowser + */ + +this.EXPORTED_SYMBOLS = [ "gDevTools", "gDevToolsBrowser" ]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +const { loader } = Cu.import("resource://devtools/shared/Loader.jsm", {}); + +/** + * Do not directly map to the commonjs modules so that callsites of + * gDevTools.jsm do not have to do anything to access to the very last version + * of the module. The `devtools` and `browser` getter are always going to + * retrieve the very last version of the modules. + */ +Object.defineProperty(this, "require", { + get() { + let { require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); + return require; + } +}); +Object.defineProperty(this, "devtools", { + get() { + return require("devtools/client/framework/devtools").gDevTools; + } +}); +Object.defineProperty(this, "browser", { + get() { + return require("devtools/client/framework/devtools-browser").gDevToolsBrowser; + } +}); + +/** + * gDevTools is a singleton that controls the Firefox Developer Tools. + * + * It is an instance of a DevTools class that holds a set of tools. It has the + * same lifetime as the browser. + */ +let gDevToolsMethods = [ + // Used by the reload addon. + // Force reloading dependencies if the loader happens to have reloaded. + "reload", + + // Used by: - b2g desktop.js + // - nsContextMenu + // - /devtools code + "showToolbox", + + // Used by Addon SDK and /devtools + "closeToolbox", + "getToolbox", + + // Used by Addon SDK, main.js and tests: + "registerTool", + "registerTheme", + "unregisterTool", + "unregisterTheme", + + // Used by main.js and test + "getToolDefinitionArray", + "getThemeDefinitionArray", + + // Used by theme-switching.js + "getThemeDefinition", + "emit", + + // Used by /devtools + "on", + "off", + "once", + + // Used by tests + "getToolDefinitionMap", + "getThemeDefinitionMap", + "getDefaultTools", + "getAdditionalTools", + "getToolDefinition", +]; +this.gDevTools = { + // Used by tests + get _toolboxes() { + return devtools._toolboxes; + }, + get _tools() { + return devtools._tools; + }, + *[Symbol.iterator ]() { + for (let toolbox of this._toolboxes) { + yield toolbox; + } + } +}; +gDevToolsMethods.forEach(name => { + this.gDevTools[name] = (...args) => { + return devtools[name].apply(devtools, args); + }; +}); + + +/** + * gDevToolsBrowser exposes functions to connect the gDevTools instance with a + * Firefox instance. + */ +let gDevToolsBrowserMethods = [ + // used by browser-sets.inc, command + "toggleToolboxCommand", + + // Used by browser.js itself, by setting a oncommand string... + "selectToolCommand", + + // Used by browser-sets.inc, command + "openAboutDebugging", + + // Used by browser-sets.inc, command + "openConnectScreen", + + // Used by browser-sets.inc, command + // itself, webide widget + "openWebIDE", + + // Used by browser-sets.inc, command + "openContentProcessToolbox", + + // Used by webide.js + "moveWebIDEWidgetInNavbar", + + // Used by browser.js + "registerBrowserWindow", + + // Used by reload addon + "hasToolboxOpened", + + // Used by browser.js + "forgetBrowserWindow" +]; +this.gDevToolsBrowser = { + // Used by webide.js + get isWebIDEInitialized() { + return browser.isWebIDEInitialized; + }, + // Used by a test (should be removed) + get _trackedBrowserWindows() { + return browser._trackedBrowserWindows; + } +}; +gDevToolsBrowserMethods.forEach(name => { + this.gDevToolsBrowser[name] = (...args) => { + return browser[name].apply(browser, args); + }; +}); diff --git a/devtools/client/framework/location-store.js b/devtools/client/framework/location-store.js new file mode 100644 index 000000000..96deb0a99 --- /dev/null +++ b/devtools/client/framework/location-store.js @@ -0,0 +1,103 @@ +/* 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_TOKEN = "<:>"; + +function LocationStore (store) { + this._store = store || new Map(); +} + +/** + * Method to get a promised location from the Store. + * @param location + * @returns Promise<Object> + */ +LocationStore.prototype.get = function (location) { + this._safeAccessInit(location.url); + return this._store.get(location.url).get(location); +}; + +/** + * Method to set a promised location to the Store + * @param location + * @param promisedLocation + */ +LocationStore.prototype.set = function (location, promisedLocation = null) { + this._safeAccessInit(location.url); + this._store.get(location.url).set(serialize(location), promisedLocation); +}; + +/** + * Utility method to verify if key exists in Store before accessing it. + * If not, initializing it. + * @param url + * @private + */ +LocationStore.prototype._safeAccessInit = function (url) { + if (!this._store.has(url)) { + this._store.set(url, new Map()); + } +}; + +/** + * Utility proxy method to Map.clear() method + */ +LocationStore.prototype.clear = function () { + this._store.clear(); +}; + +/** + * Retrieves an object containing all locations to be resolved when `source-updated` + * event is triggered. + * @param url + * @returns {Array<String>} + */ +LocationStore.prototype.getByURL = function (url){ + if (this._store.has(url)) { + return [...this._store.get(url).keys()]; + } + return []; +}; + +/** + * Invalidates the stale location promises from the store when `source-updated` + * event is triggered, and when FrameView unsubscribes from a location. + * @param url + */ +LocationStore.prototype.clearByURL = function (url) { + this._safeAccessInit(url); + this._store.set(url, new Map()); +}; + +exports.LocationStore = LocationStore; +exports.serialize = serialize; +exports.deserialize = deserialize; + +/** + * Utility method to serialize the source + * @param source + * @returns {string} + */ +function serialize(source) { + let { url, line, column } = source; + line = line || 0; + column = column || 0; + return `${url}${SOURCE_TOKEN}${line}${SOURCE_TOKEN}${column}`; +}; + +/** + * Utility method to serialize the source + * @param source + * @returns Object + */ +function deserialize(source) { + let [ url, line, column ] = source.split(SOURCE_TOKEN); + line = parseInt(line); + column = parseInt(column); + if (column === 0) { + return { url, line }; + } + return { url, line, column }; +}; diff --git a/devtools/client/framework/menu-item.js b/devtools/client/framework/menu-item.js new file mode 100644 index 000000000..f6afefa41 --- /dev/null +++ b/devtools/client/framework/menu-item.js @@ -0,0 +1,65 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 + * - icon NativeImage + * - position String - This field allows fine-grained definition of the + * specific location within a given menu. + * + * Implemented features: + * @param Object options + * 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 + * 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({ + accesskey = null, + checked = false, + click = () => {}, + disabled = false, + label = "", + id = null, + submenu = null, + type = "normal", + visible = true, +} = { }) { + this.accesskey = accesskey; + this.checked = checked; + this.click = click; + this.disabled = disabled; + this.id = id; + this.label = label; + 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 000000000..c96dbc2c7 --- /dev/null +++ b/devtools/client/framework/menu.js @@ -0,0 +1,173 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("devtools/shared/event-emitter"); + +/** + * 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); +}; + +/** + * 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 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 Toolbox toolbox (non standard) + * Needed so we in which window to inject XUL + */ +Menu.prototype.popup = function (screenX, screenY, toolbox) { + let doc = toolbox.doc; + let popupset = doc.querySelector("popupset"); + // 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. + let popup = popupset.querySelector("menupopup[menu-api=\"true\"]"); + if (popup) { + popup.hidePopup(); + } + + popup = doc.createElement("menupopup"); + popup.setAttribute("menu-api", "true"); + + if (this.id) { + popup.id = this.id; + } + this._createMenuItems(popup); + + // Remove the menu from the DOM once it's hidden. + popup.addEventListener("popuphidden", (e) => { + if (e.target === popup) { + 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) { + let doc = parent.ownerDocument; + this.menuitems.forEach(item => { + if (!item.visible) { + return; + } + + if (item.submenu) { + let menupopup = doc.createElement("menupopup"); + item.submenu._createMenuItems(menupopup); + + let menu = doc.createElement("menu"); + menu.appendChild(menupopup); + menu.setAttribute("label", item.label); + if (item.disabled) { + menu.setAttribute("disabled", "true"); + } + if (item.accesskey) { + menu.setAttribute("accesskey", item.accesskey); + } + if (item.id) { + menu.id = item.id; + } + parent.appendChild(menu); + } else if (item.type === "separator") { + let menusep = doc.createElement("menuseparator"); + parent.appendChild(menusep); + } else { + let menuitem = doc.createElement("menuitem"); + menuitem.setAttribute("label", item.label); + menuitem.addEventListener("command", () => { + item.click(); + }); + + if (item.type === "checkbox") { + menuitem.setAttribute("type", "checkbox"); + } + if (item.type === "radio") { + menuitem.setAttribute("type", "radio"); + } + if (item.disabled) { + menuitem.setAttribute("disabled", "true"); + } + if (item.checked) { + menuitem.setAttribute("checked", "true"); + } + if (item.accesskey) { + menuitem.setAttribute("accesskey", item.accesskey); + } + if (item.id) { + menuitem.id = item.id; + } + + parent.appendChild(menuitem); + } + }); +}; + +Menu.setApplicationMenu = () => { + throw Error("Not implemented"); +}; + +Menu.sendActionToFirstResponder = () => { + throw Error("Not implemented"); +}; + +Menu.buildFromTemplate = () => { + throw Error("Not implemented"); +}; + +module.exports = Menu; diff --git a/devtools/client/framework/moz.build b/devtools/client/framework/moz.build new file mode 100644 index 000000000..7b28b4b9e --- /dev/null +++ b/devtools/client/framework/moz.build @@ -0,0 +1,33 @@ +# -*- 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.ini'] +TEST_HARNESS_FILES.xpcshell.devtools.client.framework.test += [ + 'test/shared-redux-head.js', +] + +DevToolsModules( + 'about-devtools-toolbox.js', + 'attach-thread.js', + 'browser-menus.js', + 'devtools-browser.js', + 'devtools.js', + 'gDevTools.jsm', + 'location-store.js', + 'menu-item.js', + 'menu.js', + 'selection.js', + 'sidebar.js', + 'source-map-service.js', + 'target-from-url.js', + 'target.js', + 'toolbox-highlighter-utils.js', + 'toolbox-host-manager.js', + 'toolbox-hosts.js', + 'toolbox-options.js', + 'toolbox.js', + 'ToolboxProcess.jsm', +) diff --git a/devtools/client/framework/options-panel.css b/devtools/client/framework/options-panel.css new file mode 100644 index 000000000..4aad29e7b --- /dev/null +++ b/devtools/client/framework/options-panel.css @@ -0,0 +1,107 @@ +/* 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{ + -moz-user-select: none; +} + +#options-panel-container { + overflow: auto; +} + +#options-panel { + display: block; +} + +.options-vertical-pane { + display: inline; + float: left; +} + +.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; +} + +.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; +} + +/* 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 > *, +.options-groupbox > .hidden-labels-box > checkbox { + padding: 2px; +} + +.options-groupbox > .hidden-labels-box { + padding: 0; +} + +.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; +} diff --git a/devtools/client/framework/selection.js b/devtools/client/framework/selection.js new file mode 100644 index 000000000..8125f8508 --- /dev/null +++ b/devtools/client/framework/selection.js @@ -0,0 +1,247 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 nodeConstants = require("devtools/shared/dom-node-constants"); +var EventEmitter = require("devtools/shared/event-emitter"); + +/** + * API + * + * new Selection(walker=null) + * destroy() + * node (readonly) + * setNode(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 + */ + +/** + * A Selection object. Hold a reference to a node. + * Includes some helpers, fire some helpful events. + */ +function Selection(walker) { + EventEmitter.decorate(this); + + this._onMutations = this._onMutations.bind(this); + this.setWalker(walker); +} + +exports.Selection = Selection; + +Selection.prototype = { + _walker: null, + + _onMutations: function (mutations) { + let attributeChange = false; + let pseudoChange = false; + let detached = false; + let parentNode = null; + + for (let 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: function () { + this.setWalker(null); + }, + + setWalker: function (walker) { + if (this._walker) { + this._walker.off("mutations", this._onMutations); + } + this._walker = walker; + if (this._walker) { + this._walker.on("mutations", this._onMutations); + } + }, + + setNodeFront: function (value, reason = "unknown") { + this.reason = reason; + + // If an inlineTextChild text node is being set, then set it's parent instead. + let parentNode = value && value.parentNode(); + if (value && parentNode && parentNode.inlineTextChild === value) { + value = parentNode; + } + + this._nodeFront = value; + this.emit("new-node-front", value, this.reason); + }, + + get documentFront() { + return this._walker.document(this._nodeFront); + }, + + get nodeFront() { + return this._nodeFront; + }, + + isRoot: function () { + return this.isNode() && + this.isConnected() && + this._nodeFront.isDocumentElement; + }, + + isNode: function () { + return !!this._nodeFront; + }, + + isConnected: function () { + let node = this._nodeFront; + if (!node || !node.actorID) { + return false; + } + + while (node) { + if (node === this._walker.rootNode) { + return true; + } + node = node.parentNode(); + } + return false; + }, + + isHTMLNode: function () { + let xhtmlNs = "http://www.w3.org/1999/xhtml"; + return this.isNode() && this.nodeFront.namespaceURI == xhtmlNs; + }, + + // Node type + + isElementNode: function () { + return this.isNode() && this.nodeFront.nodeType == nodeConstants.ELEMENT_NODE; + }, + + isPseudoElementNode: function () { + return this.isNode() && this.nodeFront.isPseudoElement; + }, + + isAnonymousNode: function () { + return this.isNode() && this.nodeFront.isAnonymous; + }, + + isAttributeNode: function () { + return this.isNode() && this.nodeFront.nodeType == nodeConstants.ATTRIBUTE_NODE; + }, + + isTextNode: function () { + return this.isNode() && this.nodeFront.nodeType == nodeConstants.TEXT_NODE; + }, + + isCDATANode: function () { + return this.isNode() && this.nodeFront.nodeType == nodeConstants.CDATA_SECTION_NODE; + }, + + isEntityRefNode: function () { + return this.isNode() && + this.nodeFront.nodeType == nodeConstants.ENTITY_REFERENCE_NODE; + }, + + isEntityNode: function () { + return this.isNode() && this.nodeFront.nodeType == nodeConstants.ENTITY_NODE; + }, + + isProcessingInstructionNode: function () { + return this.isNode() && + this.nodeFront.nodeType == nodeConstants.PROCESSING_INSTRUCTION_NODE; + }, + + isCommentNode: function () { + return this.isNode() && + this.nodeFront.nodeType == nodeConstants.PROCESSING_INSTRUCTION_NODE; + }, + + isDocumentNode: function () { + return this.isNode() && this.nodeFront.nodeType == nodeConstants.DOCUMENT_NODE; + }, + + /** + * @returns true if the selection is the <body> HTML element. + */ + isBodyNode: function () { + return this.isHTMLNode() && + this.isConnected() && + this.nodeFront.nodeName === "BODY"; + }, + + /** + * @returns true if the selection is the <head> HTML element. + */ + isHeadNode: function () { + return this.isHTMLNode() && + this.isConnected() && + this.nodeFront.nodeName === "HEAD"; + }, + + isDocumentTypeNode: function () { + return this.isNode() && this.nodeFront.nodeType == nodeConstants.DOCUMENT_TYPE_NODE; + }, + + isDocumentFragmentNode: function () { + return this.isNode() && + this.nodeFront.nodeType == nodeConstants.DOCUMENT_FRAGMENT_NODE; + }, + + isNotationNode: function () { + return this.isNode() && this.nodeFront.nodeType == nodeConstants.NOTATION_NODE; + }, +}; diff --git a/devtools/client/framework/sidebar.js b/devtools/client/framework/sidebar.js new file mode 100644 index 000000000..c27732b5d --- /dev/null +++ b/devtools/client/framework/sidebar.js @@ -0,0 +1,592 @@ +/* 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 Services = require("Services"); +var {Task} = require("devtools/shared/task"); +var EventEmitter = require("devtools/shared/event-emitter"); +var Telemetry = require("devtools/client/shared/telemetry"); + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties"); + +const XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +/** + * ToolSidebar provides methods to register tabs in the sidebar. + * It's assumed that the sidebar contains a xul:tabbox. + * Typically, you'll want the tabbox parameter to be a XUL tabbox like this: + * + * <tabbox id="inspector-sidebar" handleCtrlTab="false" class="devtools-sidebar-tabs"> + * <tabs/> + * <tabpanels flex="1"/> + * </tabbox> + * + * The ToolSidebar API has a method to add new tabs, so the tabs and tabpanels + * nodes can be empty. But they can also already contain items before the + * ToolSidebar is created. + * + * Tabs added through the addTab method are only identified by an ID and a URL + * which is used as the href of an iframe node that is inserted in the newly + * created tabpanel. + * Tabs already present before the ToolSidebar is created may contain anything. + * However, these tabs must have ID attributes if it is required for the various + * methods that accept an ID as argument to work here. + * + * @param {Node} tabbox + * <tabbox> node; + * @param {ToolPanel} panel + * Related ToolPanel instance; + * @param {String} uid + * Unique ID + * @param {Object} options + * - hideTabstripe: Should the tabs be hidden. Defaults to false + * - showAllTabsMenu: Should a drop-down menu be displayed in case tabs + * become hidden. Defaults to false. + * - disableTelemetry: By default, switching tabs on and off in the sidebar + * will record tool usage in telemetry, pass this option to true to avoid it. + * + * Events raised: + * - new-tab-registered : After a tab has been added via addTab. The tab ID + * is passed with the event. This however, is raised before the tab iframe + * is fully loaded. + * - <tabid>-ready : After the tab iframe has been loaded + * - <tabid>-selected : After tab <tabid> was selected + * - select : Same as above, but for any tab, the ID is passed with the event + * - <tabid>-unselected : After tab <tabid> is unselected + */ +function ToolSidebar(tabbox, panel, uid, options = {}) { + EventEmitter.decorate(this); + + this._tabbox = tabbox; + this._uid = uid; + this._panelDoc = this._tabbox.ownerDocument; + this._toolPanel = panel; + this._options = options; + + this._onTabBoxOverflow = this._onTabBoxOverflow.bind(this); + this._onTabBoxUnderflow = this._onTabBoxUnderflow.bind(this); + + try { + this._width = Services.prefs.getIntPref("devtools.toolsidebar-width." + this._uid); + } catch (e) {} + + if (!options.disableTelemetry) { + this._telemetry = new Telemetry(); + } + + this._tabbox.tabpanels.addEventListener("select", this, true); + + this._tabs = new Map(); + + // Check for existing tabs in the DOM and add them. + this.addExistingTabs(); + + if (this._options.hideTabstripe) { + this._tabbox.setAttribute("hidetabs", "true"); + } + + if (this._options.showAllTabsMenu) { + this.addAllTabsMenu(); + } + + this._toolPanel.emit("sidebar-created", this); +} + +exports.ToolSidebar = ToolSidebar; + +ToolSidebar.prototype = { + TAB_ID_PREFIX: "sidebar-tab-", + + TABPANEL_ID_PREFIX: "sidebar-panel-", + + /** + * Add a "…" button at the end of the tabstripe that toggles a dropdown menu + * containing the list of all tabs if any become hidden due to lack of room. + * + * If the ToolSidebar was created with the "showAllTabsMenu" option set to + * true, this is already done automatically. If not, you may call this + * function at any time to add the menu. + */ + addAllTabsMenu: function () { + if (this._allTabsBtn) { + return; + } + + let tabs = this._tabbox.tabs; + + // Create a container and insert it first in the tabbox + let allTabsContainer = this._panelDoc.createElementNS(XULNS, "stack"); + this._tabbox.insertBefore(allTabsContainer, tabs); + + // Move the tabs inside and make them flex + allTabsContainer.appendChild(tabs); + tabs.setAttribute("flex", "1"); + + // Create the dropdown menu next to the tabs + this._allTabsBtn = this._panelDoc.createElementNS(XULNS, "toolbarbutton"); + this._allTabsBtn.setAttribute("class", "devtools-sidebar-alltabs"); + this._allTabsBtn.setAttribute("end", "0"); + this._allTabsBtn.setAttribute("top", "0"); + this._allTabsBtn.setAttribute("width", "15"); + this._allTabsBtn.setAttribute("type", "menu"); + this._allTabsBtn.setAttribute("tooltiptext", + L10N.getStr("sidebar.showAllTabs.tooltip")); + this._allTabsBtn.setAttribute("hidden", "true"); + allTabsContainer.appendChild(this._allTabsBtn); + + let menuPopup = this._panelDoc.createElementNS(XULNS, "menupopup"); + this._allTabsBtn.appendChild(menuPopup); + + // Listening to tabs overflow event to toggle the alltabs button + tabs.addEventListener("overflow", this._onTabBoxOverflow, false); + tabs.addEventListener("underflow", this._onTabBoxUnderflow, false); + + // Add menuitems to the alltabs menu if there are already tabs in the + // sidebar + for (let [id, tab] of this._tabs) { + let item = this._addItemToAllTabsMenu(id, tab, { + selected: tab.hasAttribute("selected") + }); + if (tab.hidden) { + item.hidden = true; + } + } + }, + + removeAllTabsMenu: function () { + if (!this._allTabsBtn) { + return; + } + + let tabs = this._tabbox.tabs; + + tabs.removeEventListener("overflow", this._onTabBoxOverflow, false); + tabs.removeEventListener("underflow", this._onTabBoxUnderflow, false); + + // Moving back the tabs as a first child of the tabbox + this._tabbox.insertBefore(tabs, this._tabbox.tabpanels); + this._tabbox.querySelector("stack").remove(); + + this._allTabsBtn = null; + }, + + _onTabBoxOverflow: function () { + this._allTabsBtn.removeAttribute("hidden"); + }, + + _onTabBoxUnderflow: function () { + this._allTabsBtn.setAttribute("hidden", "true"); + }, + + /** + * Add an item in the allTabs menu for a given tab. + */ + _addItemToAllTabsMenu: function (id, tab, options) { + if (!this._allTabsBtn) { + return; + } + + let item = this._panelDoc.createElementNS(XULNS, "menuitem"); + let idPrefix = "sidebar-alltabs-item-"; + item.setAttribute("id", idPrefix + id); + item.setAttribute("label", tab.getAttribute("label")); + item.setAttribute("type", "checkbox"); + if (options.selected) { + item.setAttribute("checked", true); + } + // The auto-checking of menuitems in this menu doesn't work, so let's do + // it manually + item.setAttribute("autocheck", false); + + let menu = this._allTabsBtn.querySelector("menupopup"); + if (options.insertBefore) { + let referenceItem = menu.querySelector(`#${idPrefix}${options.insertBefore}`); + menu.insertBefore(item, referenceItem); + } else { + menu.appendChild(item); + } + + item.addEventListener("click", () => { + this._tabbox.selectedTab = tab; + }, false); + + tab.allTabsMenuItem = item; + + return item; + }, + + /** + * Register a tab. A tab is a document. + * The document must have a title, which will be used as the name of the tab. + * + * @param {string} id The unique id for this tab. + * @param {string} url The URL of the document to load in this new tab. + * @param {Object} options A set of options for this new tab: + * - {Boolean} selected Set to true to make this new tab selected by default. + * - {String} insertBefore By default, the new tab is appended at the end of the + * tabbox, pass the ID of an existing tab to insert it before that tab instead. + */ + addTab: function (id, url, options = {}) { + let iframe = this._panelDoc.createElementNS(XULNS, "iframe"); + iframe.className = "iframe-" + id; + iframe.setAttribute("flex", "1"); + iframe.setAttribute("src", url); + iframe.tooltip = "aHTMLTooltip"; + + // Creating the tab and adding it to the tabbox + let tab = this._panelDoc.createElementNS(XULNS, "tab"); + + tab.setAttribute("id", this.TAB_ID_PREFIX + id); + tab.setAttribute("crop", "end"); + // Avoid showing "undefined" while the tab is loading + tab.setAttribute("label", ""); + + if (options.insertBefore) { + let referenceTab = this.getTab(options.insertBefore); + this._tabbox.tabs.insertBefore(tab, referenceTab); + } else { + this._tabbox.tabs.appendChild(tab); + } + + // Add the tab to the allTabs menu if exists + let allTabsItem = this._addItemToAllTabsMenu(id, tab, options); + + let onIFrameLoaded = (event) => { + let doc = event.target; + let win = doc.defaultView; + tab.setAttribute("label", doc.title); + + if (allTabsItem) { + allTabsItem.setAttribute("label", doc.title); + } + + iframe.removeEventListener("load", onIFrameLoaded, true); + if ("setPanel" in win) { + win.setPanel(this._toolPanel, iframe); + } + this.emit(id + "-ready"); + }; + + iframe.addEventListener("load", onIFrameLoaded, true); + + let tabpanel = this._panelDoc.createElementNS(XULNS, "tabpanel"); + tabpanel.setAttribute("id", this.TABPANEL_ID_PREFIX + id); + tabpanel.appendChild(iframe); + + if (options.insertBefore) { + let referenceTabpanel = this.getTabPanel(options.insertBefore); + this._tabbox.tabpanels.insertBefore(tabpanel, referenceTabpanel); + } else { + this._tabbox.tabpanels.appendChild(tabpanel); + } + + this._tooltip = this._panelDoc.createElementNS(XULNS, "tooltip"); + this._tooltip.id = "aHTMLTooltip"; + tabpanel.appendChild(this._tooltip); + this._tooltip.page = true; + + tab.linkedPanel = this.TABPANEL_ID_PREFIX + id; + + // We store the index of this tab. + this._tabs.set(id, tab); + + if (options.selected) { + this._selectTabSoon(id); + } + + this.emit("new-tab-registered", id); + }, + + untitledTabsIndex: 0, + + /** + * Search for existing tabs in the markup that aren't know yet and add them. + */ + addExistingTabs: function () { + let knownTabs = [...this._tabs.values()]; + + for (let tab of this._tabbox.tabs.querySelectorAll("tab")) { + if (knownTabs.indexOf(tab) !== -1) { + continue; + } + + // Find an ID for this unknown tab + let id = tab.getAttribute("id") || "untitled-tab-" + (this.untitledTabsIndex++); + + // If the existing tab contains the tab ID prefix, extract the ID of the + // tab + if (id.startsWith(this.TAB_ID_PREFIX)) { + id = id.split(this.TAB_ID_PREFIX).pop(); + } + + // Register the tab + this._tabs.set(id, tab); + this.emit("new-tab-registered", id); + } + }, + + /** + * Remove an existing tab. + * @param {String} tabId The ID of the tab that was used to register it, or + * the tab id attribute value if the tab existed before the sidebar got created. + * @param {String} tabPanelId Optional. If provided, this ID will be used + * instead of the tabId to retrieve and remove the corresponding <tabpanel> + */ + removeTab: Task.async(function* (tabId, tabPanelId) { + // Remove the tab if it can be found + let tab = this.getTab(tabId); + if (!tab) { + return; + } + + let win = this.getWindowForTab(tabId); + if (win && ("destroy" in win)) { + yield win.destroy(); + } + + tab.remove(); + + // Also remove the tabpanel + let panel = this.getTabPanel(tabPanelId || tabId); + if (panel) { + panel.remove(); + } + + this._tabs.delete(tabId); + this.emit("tab-unregistered", tabId); + }), + + /** + * Show or hide a specific tab. + * @param {Boolean} isVisible True to show the tab/tabpanel, False to hide it. + * @param {String} id The ID of the tab to be hidden. + */ + toggleTab: function (isVisible, id) { + // Toggle the tab. + let tab = this.getTab(id); + if (!tab) { + return; + } + tab.hidden = !isVisible; + + // Toggle the item in the allTabs menu. + if (this._allTabsBtn) { + this._allTabsBtn.querySelector("#sidebar-alltabs-item-" + id).hidden = !isVisible; + } + }, + + /** + * Select a specific tab. + */ + select: function (id) { + let tab = this.getTab(id); + if (tab) { + this._tabbox.selectedTab = tab; + } + }, + + /** + * Hack required to select a tab right after it was created. + * + * @param {String} id + * The sidebar tab id to select. + */ + _selectTabSoon: function (id) { + this._panelDoc.defaultView.setTimeout(() => { + this.select(id); + }, 0); + }, + + /** + * Return the id of the selected tab. + */ + getCurrentTabID: function () { + let currentID = null; + for (let [id, tab] of this._tabs) { + if (this._tabbox.tabs.selectedItem == tab) { + currentID = id; + break; + } + } + return currentID; + }, + + /** + * Returns the requested tab panel based on the id. + * @param {String} id + * @return {DOMNode} + */ + getTabPanel: function (id) { + // Search with and without the ID prefix as there might have been existing + // tabpanels by the time the sidebar got created + return this._tabbox.tabpanels.querySelector("#" + this.TABPANEL_ID_PREFIX + id + ", #" + id); + }, + + /** + * Return the tab based on the provided id, if one was registered with this id. + * @param {String} id + * @return {DOMNode} + */ + getTab: function (id) { + return this._tabs.get(id); + }, + + /** + * Event handler. + */ + handleEvent: function (event) { + if (event.type !== "select" || this._destroyed) { + return; + } + + if (this._currentTool == this.getCurrentTabID()) { + // Tool hasn't changed. + return; + } + + let previousTool = this._currentTool; + this._currentTool = this.getCurrentTabID(); + if (previousTool) { + if (this._telemetry) { + this._telemetry.toolClosed(previousTool); + } + this.emit(previousTool + "-unselected"); + } + + if (this._telemetry) { + this._telemetry.toolOpened(this._currentTool); + } + + this.emit(this._currentTool + "-selected"); + this.emit("select", this._currentTool); + + // Handlers for "select"/"...-selected"/"...-unselected" events might have + // destroyed the sidebar in the meantime. + if (this._destroyed) { + return; + } + + // Handle menuitem selection if the allTabsMenu is there by unchecking all + // items except the selected one. + let tab = this._tabbox.selectedTab; + if (tab.allTabsMenuItem) { + for (let otherItem of this._allTabsBtn.querySelectorAll("menuitem")) { + otherItem.removeAttribute("checked"); + } + tab.allTabsMenuItem.setAttribute("checked", true); + } + }, + + /** + * Toggle sidebar's visibility state. + */ + toggle: function () { + if (this._tabbox.hasAttribute("hidden")) { + this.show(); + } else { + this.hide(); + } + }, + + /** + * Show the sidebar. + * + * @param {String} id + * The sidebar tab id to select. + */ + show: function (id) { + if (this._width) { + this._tabbox.width = this._width; + } + this._tabbox.removeAttribute("hidden"); + + // If an id is given, select the corresponding sidebar tab and record the + // tool opened. + if (id) { + this._currentTool = id; + + if (this._telemetry) { + this._telemetry.toolOpened(this._currentTool); + } + + this._selectTabSoon(id); + } + + this.emit("show"); + }, + + /** + * Show the sidebar. + */ + hide: function () { + Services.prefs.setIntPref("devtools.toolsidebar-width." + this._uid, this._tabbox.width); + this._tabbox.setAttribute("hidden", "true"); + this._panelDoc.activeElement.blur(); + + this.emit("hide"); + }, + + /** + * Return the window containing the tab content. + */ + getWindowForTab: function (id) { + if (!this._tabs.has(id)) { + return null; + } + + // Get the tabpanel and make sure it contains an iframe + let panel = this.getTabPanel(id); + if (!panel || !panel.firstChild || !panel.firstChild.contentWindow) { + return; + } + return panel.firstChild.contentWindow; + }, + + /** + * Clean-up. + */ + destroy: Task.async(function* () { + if (this._destroyed) { + return; + } + this._destroyed = true; + + Services.prefs.setIntPref("devtools.toolsidebar-width." + this._uid, this._tabbox.width); + + if (this._allTabsBtn) { + this.removeAllTabsMenu(); + } + + this._tabbox.tabpanels.removeEventListener("select", this, true); + + // Note that we check for the existence of this._tabbox.tabpanels at each + // step as the container window may have been closed by the time one of the + // panel's destroy promise resolves. + while (this._tabbox.tabpanels && this._tabbox.tabpanels.hasChildNodes()) { + let panel = this._tabbox.tabpanels.firstChild; + let win = panel.firstChild.contentWindow; + if (win && ("destroy" in win)) { + yield win.destroy(); + } + panel.remove(); + } + + while (this._tabbox.tabs && this._tabbox.tabs.hasChildNodes()) { + this._tabbox.tabs.removeChild(this._tabbox.tabs.firstChild); + } + + if (this._currentTool && this._telemetry) { + this._telemetry.toolClosed(this._currentTool); + } + + this._toolPanel.emit("sidebar-destroyed", this); + + this._tabs = null; + this._tabbox = null; + this._panelDoc = null; + this._toolPanel = null; + }) +}; diff --git a/devtools/client/framework/source-map-service.js b/devtools/client/framework/source-map-service.js new file mode 100644 index 000000000..838adc392 --- /dev/null +++ b/devtools/client/framework/source-map-service.js @@ -0,0 +1,209 @@ +/* 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 { Task } = require("devtools/shared/task"); +const EventEmitter = require("devtools/shared/event-emitter"); +const { LocationStore, serialize, deserialize } = require("./location-store"); + +/** + * A manager class that wraps a TabTarget and listens to source changes + * from source maps and resolves non-source mapped locations to the source mapped + * versions and back and forth, and creating smart elements with a location that + * auto-update when the source changes (from pretty printing, source maps loading, etc) + * + * @param {TabTarget} target + */ + +function SourceMapService(target) { + this._target = target; + this._locationStore = new LocationStore(); + this._isNotSourceMapped = new Map(); + + EventEmitter.decorate(this); + + this._onSourceUpdated = this._onSourceUpdated.bind(this); + this._resolveLocation = this._resolveLocation.bind(this); + this._resolveAndUpdate = this._resolveAndUpdate.bind(this); + this.subscribe = this.subscribe.bind(this); + this.unsubscribe = this.unsubscribe.bind(this); + this.reset = this.reset.bind(this); + this.destroy = this.destroy.bind(this); + + target.on("source-updated", this._onSourceUpdated); + target.on("navigate", this.reset); + target.on("will-navigate", this.reset); +} + +/** + * Clears the store containing the cached promised locations + */ +SourceMapService.prototype.reset = function () { + // Guard to prevent clearing the store when it is not initialized yet. + if (!this._locationStore) { + return; + } + this._locationStore.clear(); + this._isNotSourceMapped.clear(); +}; + +SourceMapService.prototype.destroy = function () { + this.reset(); + this._target.off("source-updated", this._onSourceUpdated); + this._target.off("navigate", this.reset); + this._target.off("will-navigate", this.reset); + this._target.off("close", this.destroy); + this._target = this._locationStore = this._isNotSourceMapped = null; +}; + +/** + * Sets up listener for the callback to update the FrameView + * and tries to resolve location, if it is source-mappable + * @param location + * @param callback + */ +SourceMapService.prototype.subscribe = function (location, callback) { + // A valid candidate location for source-mapping should have a url and line. + // Abort if there's no `url`, which means it's unsourcemappable anyway, + // like an eval script. + // From previous attempts to source-map locations, we also determine if a location + // is not source-mapped. + if (!location.url || !location.line || this._isNotSourceMapped.get(location.url)) { + return; + } + this.on(serialize(location), callback); + this._locationStore.set(location); + this._resolveAndUpdate(location); +}; + +/** + * Removes the listener for the location and clears cached locations + * @param location + * @param callback + */ +SourceMapService.prototype.unsubscribe = function (location, callback) { + this.off(serialize(location), callback); + // Check to see if the store exists before attempting to clear a location + // Sometimes un-subscribe happens during the destruction cascades and this + // condition is to protect against that. Could be looked into in the future. + if (!this._locationStore) { + return; + } + this._locationStore.clearByURL(location.url); +}; + +/** + * Tries to resolve the location and if successful, + * emits the resolved location + * @param location + * @private + */ +SourceMapService.prototype._resolveAndUpdate = function (location) { + this._resolveLocation(location).then(resolvedLocation => { + // We try to source map the first console log to initiate the source-updated + // event from target. The isSameLocation check is to make sure we don't update + // the frame, if the location is not source-mapped. + if (resolvedLocation && !isSameLocation(location, resolvedLocation)) { + this.emit(serialize(location), location, resolvedLocation); + } + }); +}; + +/** + * Checks if there is existing promise to resolve location, if so returns cached promise + * if not, tries to resolve location and returns a promised location + * @param location + * @return Promise<Object> + * @private + */ +SourceMapService.prototype._resolveLocation = Task.async(function* (location) { + let resolvedLocation; + const cachedLocation = this._locationStore.get(location); + if (cachedLocation) { + resolvedLocation = cachedLocation; + } else { + const promisedLocation = resolveLocation(this._target, location); + if (promisedLocation) { + this._locationStore.set(location, promisedLocation); + resolvedLocation = promisedLocation; + } + } + return resolvedLocation; +}); + +/** + * Checks if the `source-updated` event is fired from the target. + * Checks to see if location store has the source url in its cache, + * if so, tries to update each stale location in the store. + * Determines if the source should be source-mapped or not. + * @param _ + * @param sourceEvent + * @private + */ +SourceMapService.prototype._onSourceUpdated = function (_, sourceEvent) { + let { type, source } = sourceEvent; + + // If we get a new source, and it's not a source map, abort; + // we can have no actionable updates as this is just a new normal source. + // Check Source Actor for sourceMapURL property (after Firefox 48) + // If not present, utilize isSourceMapped and isPrettyPrinted properties + // to estimate if a source is not source-mapped. + const isNotSourceMapped = !(source.sourceMapURL || + source.isSourceMapped || source.isPrettyPrinted); + if (type === "newSource" && isNotSourceMapped) { + this._isNotSourceMapped.set(source.url, true); + return; + } + let sourceUrl = null; + if (source.generatedUrl && source.isSourceMapped) { + sourceUrl = source.generatedUrl; + } else if (source.url && source.isPrettyPrinted) { + sourceUrl = source.url; + } + const locationsToResolve = this._locationStore.getByURL(sourceUrl); + if (locationsToResolve.length) { + this._locationStore.clearByURL(sourceUrl); + for (let location of locationsToResolve) { + this._resolveAndUpdate(deserialize(location)); + } + } +}; + +exports.SourceMapService = SourceMapService; + +/** + * Take a TabTarget and a location, containing a `url`, `line`, and `column`, resolve + * the location to the latest location (so a source mapped location, or if pretty print + * status has been updated) + * + * @param {TabTarget} target + * @param {Object} location + * @return {Promise<Object>} + */ +function resolveLocation(target, location) { + return Task.spawn(function* () { + let newLocation = yield target.resolveLocation({ + url: location.url, + line: location.line, + column: location.column || Infinity + }); + // Source or mapping not found, so don't do anything + if (newLocation.error) { + return null; + } + return newLocation; + }); +} + +/** + * Returns true if the original location and resolved location are the same + * @param location + * @param resolvedLocation + * @returns {boolean} + */ +function isSameLocation(location, resolvedLocation) { + return location.url === resolvedLocation.url && + location.line === resolvedLocation.line && + location.column === resolvedLocation.column; +} diff --git a/devtools/client/framework/source-map-util.js b/devtools/client/framework/source-map-util.js new file mode 100644 index 000000000..0bb25b3df --- /dev/null +++ b/devtools/client/framework/source-map-util.js @@ -0,0 +1,20 @@ +function originalToGeneratedId(originalId) { + const match = originalId.match(/(.*)\/originalSource/); + return match ? match[1] : ""; +} + +function generatedToOriginalId(generatedId, url) { + return generatedId + "/originalSource-" + url.replace(/ \//, '-'); +} + +function isOriginalId(id) { + return !!id.match(/\/originalSource/); +} + +function isGeneratedId(id) { + return !isOriginalId(id); +} + +module.exports = { + originalToGeneratedId, generatedToOriginalId, isOriginalId, isGeneratedId +}; diff --git a/devtools/client/framework/source-map-worker.js b/devtools/client/framework/source-map-worker.js new file mode 100644 index 000000000..c68732f38 --- /dev/null +++ b/devtools/client/framework/source-map-worker.js @@ -0,0 +1,220 @@ +const { fetch, assert } = require("devtools/shared/DevToolsUtils"); +const { joinURI } = require("devtools/shared/path"); +const path = require("sdk/fs/path"); +const { SourceMapConsumer, SourceMapGenerator } = require("source-map"); +const { isJavaScript } = require("./source"); +const { + originalToGeneratedId, + generatedToOriginalId, + isGeneratedId, + isOriginalId +} = require("./source-map-util"); + +let sourceMapRequests = new Map(); +let sourceMapsEnabled = false; + +function clearSourceMaps() { + sourceMapRequests.clear(); +} + +function enableSourceMaps() { + sourceMapsEnabled = true; +} + +function _resolveSourceMapURL(source) { + const { url = "", sourceMapURL = "" } = source; + if (path.isURL(sourceMapURL) || url == "") { + // If it's already a full URL or the source doesn't have a URL, + // don't resolve anything. + return sourceMapURL; + } else if (path.isAbsolute(sourceMapURL)) { + // If it's an absolute path, it should be resolved relative to the + // host of the source. + const { protocol = "", host = "" } = parse(url); + return `${protocol}//${host}${sourceMapURL}`; + } + // Otherwise, it's a relative path and should be resolved relative + // to the source. + return dirname(url) + "/" + sourceMapURL; +} + +/** + * Sets the source map's sourceRoot to be relative to the source map url. + * @memberof utils/source-map-worker + * @static + */ +function _setSourceMapRoot(sourceMap, absSourceMapURL, source) { + // No need to do this fiddling if we won't be fetching any sources over the + // wire. + if (sourceMap.hasContentsOfAllSources()) { + return; + } + + const base = dirname( + (absSourceMapURL.indexOf("data:") === 0 && source.url) ? + source.url : + absSourceMapURL + ); + + if (sourceMap.sourceRoot) { + sourceMap.sourceRoot = joinURI(base, sourceMap.sourceRoot); + } else { + sourceMap.sourceRoot = base; + } + + return sourceMap; +} + +function _getSourceMap(generatedSourceId) + : ?Promise<SourceMapConsumer> { + return sourceMapRequests.get(generatedSourceId); +} + +async function _resolveAndFetch(generatedSource) : SourceMapConsumer { + // Fetch the sourcemap over the network and create it. + const sourceMapURL = _resolveSourceMapURL(generatedSource); + const fetched = await fetch( + sourceMapURL, { loadFromCache: false } + ); + + // Create the source map and fix it up. + const map = new SourceMapConsumer(fetched.content); + _setSourceMapRoot(map, sourceMapURL, generatedSource); + return map; +} + +function _fetchSourceMap(generatedSource) { + const existingRequest = sourceMapRequests.get(generatedSource.id); + if (existingRequest) { + // If it has already been requested, return the request. Make sure + // to do this even if sourcemapping is turned off, because + // pretty-printing uses sourcemaps. + // + // An important behavior here is that if it's in the middle of + // requesting it, all subsequent calls will block on the initial + // request. + return existingRequest; + } else if (!generatedSource.sourceMapURL || !sourceMapsEnabled) { + return Promise.resolve(null); + } + + // Fire off the request, set it in the cache, and return it. + // Suppress any errors and just return null (ignores bogus + // sourcemaps). + const req = _resolveAndFetch(generatedSource).catch(() => null); + sourceMapRequests.set(generatedSource.id, req); + return req; +} + +async function getOriginalURLs(generatedSource) { + const map = await _fetchSourceMap(generatedSource); + return map && map.sources; +} + +async function getGeneratedLocation(location: Location, originalSource: Source) + : Promise<Location> { + if (!isOriginalId(location.sourceId)) { + return location; + } + + const generatedSourceId = originalToGeneratedId(location.sourceId); + const map = await _getSourceMap(generatedSourceId); + if (!map) { + return location; + } + + const { line, column } = map.generatedPositionFor({ + source: originalSource.url, + line: location.line, + column: location.column == null ? 0 : location.column + }); + + return { + sourceId: generatedSourceId, + line: line, + // Treat 0 as no column so that line breakpoints work correctly. + column: column === 0 ? undefined : column + }; +} + +async function getOriginalLocation(location) { + if (!isGeneratedId(location.sourceId)) { + return location; + } + + const map = await _getSourceMap(location.sourceId); + if (!map) { + return location; + } + + const { source: url, line, column } = map.originalPositionFor({ + line: location.line, + column: location.column == null ? Infinity : location.column + }); + + if (url == null) { + // No url means the location didn't map. + return location; + } + + return { + sourceId: generatedToOriginalId(location.sourceId, url), + line, + column + }; +} + +async function getOriginalSourceText(originalSource) { + assert(isOriginalId(originalSource.id), + "Source is not an original source"); + + const generatedSourceId = originalToGeneratedId(originalSource.id); + const map = await _getSourceMap(generatedSourceId); + if (!map) { + return null; + } + + let text = map.sourceContentFor(originalSource.url); + if (!text) { + text = (await fetch( + originalSource.url, { loadFromCache: false } + )).content; + } + + return { + text, + contentType: isJavaScript(originalSource.url || "") ? + "text/javascript" : + "text/plain" + }; +} + +function applySourceMap(generatedId, url, code, mappings) { + const generator = new SourceMapGenerator({ file: url }); + mappings.forEach(mapping => generator.addMapping(mapping)); + generator.setSourceContent(url, code); + + const map = SourceMapConsumer(generator.toJSON()); + sourceMapRequests.set(generatedId, Promise.resolve(map)); +} + +const publicInterface = { + getOriginalURLs, + getGeneratedLocation, + getOriginalLocation, + getOriginalSourceText, + enableSourceMaps, + applySourceMap, + clearSourceMaps +}; + +self.onmessage = function(msg) { + const { id, method, args } = msg.data; + const response = publicInterface[method].apply(undefined, args); + if (response instanceof Promise) { + response.then(val => self.postMessage({ id, response: val }), + err => self.postMessage({ id, error: err })); + } else { + self.postMessage({ id, response }); + } +}; diff --git a/devtools/client/framework/source-map.js b/devtools/client/framework/source-map.js new file mode 100644 index 000000000..7c6805c85 --- /dev/null +++ b/devtools/client/framework/source-map.js @@ -0,0 +1,84 @@ +// @flow + +const { + originalToGeneratedId, + generatedToOriginalId, + isGeneratedId, + isOriginalId +} = require("./source-map-util"); + +function workerTask(worker, method) { + return function(...args: any) { + return new Promise((resolve, reject) => { + const id = msgId++; + worker.postMessage({ id, method, args }); + + const listener = ({ data: result }) => { + if (result.id !== id) { + return; + } + + worker.removeEventListener("message", listener); + if (result.error) { + reject(result.error); + } else { + resolve(result.response); + } + }; + + worker.addEventListener("message", listener); + }); + }; +} + +let sourceMapWorker; +function restartWorker() { + if (sourceMapWorker) { + sourceMapWorker.terminate(); + } + sourceMapWorker = new Worker( + "resource://devtools/client/framework/source-map-worker.js" + ); + + if (Services.prefs.getBoolPref("devtools.debugger.client-source-maps-enabled")) { + sourceMapWorker.postMessage({ id: 0, method: "enableSourceMaps" }); + } +} +restartWorker(); + +function destroyWorker() { + if (sourceMapWorker) { + sourceMapWorker.terminate(); + sourceMapWorker = null; + } +} + +function shouldSourceMap() { + return Services.prefs.getBoolPref("devtools.debugger.client-source-maps-enabled"); +} + +const getOriginalURLs = workerTask(sourceMapWorker, "getOriginalURLs"); +const getGeneratedLocation = workerTask(sourceMapWorker, + "getGeneratedLocation"); +const getOriginalLocation = workerTask(sourceMapWorker, + "getOriginalLocation"); +const getOriginalSourceText = workerTask(sourceMapWorker, + "getOriginalSourceText"); +const applySourceMap = workerTask(sourceMapWorker, "applySourceMap"); +const clearSourceMaps = workerTask(sourceMapWorker, "clearSourceMaps"); + +module.exports = { + originalToGeneratedId, + generatedToOriginalId, + isGeneratedId, + isOriginalId, + + getOriginalURLs, + getGeneratedLocation, + getOriginalLocation, + getOriginalSourceText, + applySourceMap, + clearSourceMaps, + destroyWorker, + shouldSourceMap +}; diff --git a/devtools/client/framework/target-from-url.js b/devtools/client/framework/target-from-url.js new file mode 100644 index 000000000..4e2c30377 --- /dev/null +++ b/devtools/client/framework/target-from-url.js @@ -0,0 +1,120 @@ +/* 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 { Cu, Ci } = require("chrome"); + +const { TargetFactory } = require("devtools/client/framework/target"); +const { DebuggerServer } = require("devtools/server/main"); +const { DebuggerClient } = require("devtools/shared/client/main"); +const { Task } = require("devtools/shared/task"); + +/** + * Construct a Target 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. + * ws: + * {Boolean} If true, connect via websocket instread of regular TCP connection. + * + * type: tab, process + * {String} The type of target to connect to. Currently tabs and processes are supported types. + * + * If type="tab": + * id: + * {Number} the tab outerWindowID + * chrome: Optional + * {Boolean} Force the creation of a chrome target. Gives more privileges to the tab + * actor. Allows chrome execution in the webconsole and see chrome files in + * the debugger. (handy when contributing to firefox) + * + * 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 target object + */ +exports.targetFromURL = Task.async(function* (url) { + let params = url.searchParams; + let type = params.get("type"); + if (!type) { + throw new Error("targetFromURL, missing type parameter"); + } + let id = params.get("id"); + // Allows to spawn a chrome enabled target for any context + // (handy to debug chrome stuff in a child process) + let chrome = params.has("chrome"); + + let client = yield createClient(params); + + yield client.connect(); + + let form, isTabActor; + if (type === "tab") { + // Fetch target for a remote tab + id = parseInt(id); + if (isNaN(id)) { + throw new Error("targetFromURL, wrong tab id:'" + id + "', should be a number"); + } + try { + let response = yield client.getTab({ outerWindowID: id }); + form = response.tab; + } catch (ex) { + if (ex.error == "noTab") { + throw new Error("targetFromURL, tab with outerWindowID:'" + id + "' doesn't exist"); + } + throw ex; + } + } else if (type == "process") { + // Fetch target for a remote chrome actor + DebuggerServer.allowChromeProcess = true; + try { + id = parseInt(id); + if (isNaN(id)) { + id = 0; + } + let response = yield client.getProcess(id); + form = response.form; + chrome = true; + if (id != 0) { + // Child process are not exposing tab actors and only support debugger+console + isTabActor = false; + } + } catch (ex) { + if (ex.error == "noProcess") { + throw new Error("targetFromURL, process with id:'" + id + "' doesn't exist"); + } + throw ex; + } + } else { + throw new Error("targetFromURL, unsupported type='" + type + "' parameter"); + } + + return TargetFactory.forRemoteTab({ client, form, chrome, isTabActor }); +}); + +function* createClient(params) { + let host = params.get("host"); + let port = params.get("port"); + let webSocket = !!params.get("ws"); + + let transport; + if (port) { + transport = yield DebuggerClient.socketConnect({ host, port, webSocket }); + } else { + // Setup a server if we don't have one already running + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + transport = DebuggerServer.connectPipe() + } + return new DebuggerClient(transport); +} diff --git a/devtools/client/framework/target.js b/devtools/client/framework/target.js new file mode 100644 index 000000000..30a720b7e --- /dev/null +++ b/devtools/client/framework/target.js @@ -0,0 +1,825 @@ +/* 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 { Ci } = require("chrome"); +const promise = require("promise"); +const defer = require("devtools/shared/defer"); +const EventEmitter = require("devtools/shared/event-emitter"); +const Services = require("Services"); +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); + +loader.lazyRequireGetter(this, "DebuggerServer", "devtools/server/main", true); +loader.lazyRequireGetter(this, "DebuggerClient", + "devtools/shared/client/main", true); +loader.lazyRequireGetter(this, "gDevTools", + "devtools/client/framework/devtools", true); + +const targets = new WeakMap(); +const promiseTargets = new WeakMap(); + +/** + * Functions for creating Targets + */ +const TargetFactory = exports.TargetFactory = { + /** + * Construct a Target + * @param {XULTab} tab + * The tab to use in creating a new target. + * + * @return A target object + */ + forTab: function (tab) { + let target = targets.get(tab); + if (target == null) { + target = new TabTarget(tab); + targets.set(tab, target); + } + return target; + }, + + /** + * Return a promise of a Target for a remote tab. + * @param {Object} options + * The options object has the following properties: + * { + * form: the remote protocol form of a tab, + * client: a DebuggerClient instance + * (caller owns this and is responsible for closing), + * chrome: true if the remote target is the whole process + * } + * + * @return A promise of a target object + */ + forRemoteTab: function (options) { + let targetPromise = promiseTargets.get(options); + if (targetPromise == null) { + let target = new TabTarget(options); + targetPromise = target.makeRemote().then(() => target); + promiseTargets.set(options, targetPromise); + } + return targetPromise; + }, + + forWorker: function (workerClient) { + let target = targets.get(workerClient); + if (target == null) { + target = new WorkerTarget(workerClient); + targets.set(workerClient, target); + } + return target; + }, + + /** + * Creating a target for a tab that is being closed is a problem because it + * allows a leak as a result of coming after the close event which normally + * clears things up. This function allows us to ask if there is a known + * target for a tab without creating a target + * @return true/false + */ + isKnownTab: function (tab) { + return targets.has(tab); + }, +}; + +/** + * A Target represents something that we can debug. Targets are generally + * read-only. Any changes that you wish to make to a target should be done via + * a Tool that attaches to the target. i.e. a Target is just a pointer saying + * "the thing to debug is over there". + * + * Providing a generalized abstraction of a web-page or web-browser (available + * either locally or remotely) is beyond the scope of this class (and maybe + * also beyond the scope of this universe) However Target does attempt to + * abstract some common events and read-only properties common to many Tools. + * + * Supported read-only properties: + * - name, isRemote, url + * + * Target extends EventEmitter and provides support for the following events: + * - close: The target window has been closed. All tools attached to this + * target should close. This event is not currently cancelable. + * - navigate: The target window has navigated to a different URL + * + * Optional events: + * - will-navigate: The target window will navigate to a different URL + * - hidden: The target is not visible anymore (for TargetTab, another tab is + * selected) + * - visible: The target is visible (for TargetTab, tab is selected) + * + * Comparing Targets: 2 instances of a Target object can point at the same + * thing, so t1 !== t2 and t1 != t2 even when they represent the same object. + * To compare to targets use 't1.equals(t2)'. + */ + +/** + * A TabTarget represents a page living in a browser tab. Generally these will + * be web pages served over http(s), but they don't have to be. + */ +function TabTarget(tab) { + EventEmitter.decorate(this); + this.destroy = this.destroy.bind(this); + this.activeTab = this.activeConsole = null; + // Only real tabs need initialization here. Placeholder objects for remote + // targets will be initialized after a makeRemote method call. + if (tab && !["client", "form", "chrome"].every(tab.hasOwnProperty, tab)) { + this._tab = tab; + this._setupListeners(); + } else { + this._form = tab.form; + this._url = this._form.url; + this._title = this._form.title; + + this._client = tab.client; + this._chrome = tab.chrome; + } + // Default isTabActor to true if not explicitly specified + if (typeof tab.isTabActor == "boolean") { + this._isTabActor = tab.isTabActor; + } else { + this._isTabActor = true; + } +} + +TabTarget.prototype = { + _webProgressListener: null, + + /** + * Returns a promise for the protocol description from the root actor. Used + * internally with `target.actorHasMethod`. Takes advantage of caching if + * definition was fetched previously with the corresponding actor information. + * Actors are lazily loaded, so not only must the tool using a specific actor + * be in use, the actors are only registered after invoking a method (for + * performance reasons, added in bug 988237), so to use these actor detection + * methods, one must already be communicating with a specific actor of that + * type. + * + * Must be a remote target. + * + * @return {Promise} + * { + * "category": "actor", + * "typeName": "longstractor", + * "methods": [{ + * "name": "substring", + * "request": { + * "type": "substring", + * "start": { + * "_arg": 0, + * "type": "primitive" + * }, + * "end": { + * "_arg": 1, + * "type": "primitive" + * } + * }, + * "response": { + * "substring": { + * "_retval": "primitive" + * } + * } + * }], + * "events": {} + * } + */ + getActorDescription: function (actorName) { + if (!this.client) { + throw new Error("TabTarget#getActorDescription() can only be called on " + + "remote tabs."); + } + + let deferred = defer(); + + if (this._protocolDescription && + this._protocolDescription.types[actorName]) { + deferred.resolve(this._protocolDescription.types[actorName]); + } else { + this.client.mainRoot.protocolDescription(description => { + this._protocolDescription = description; + deferred.resolve(description.types[actorName]); + }); + } + + return deferred.promise; + }, + + /** + * Returns a boolean indicating whether or not the specific actor + * type exists. Must be a remote target. + * + * @param {String} actorName + * @return {Boolean} + */ + hasActor: function (actorName) { + if (!this.client) { + throw new Error("TabTarget#hasActor() can only be called on remote " + + "tabs."); + } + if (this.form) { + return !!this.form[actorName + "Actor"]; + } + return false; + }, + + /** + * Queries the protocol description to see if an actor has + * an available method. The actor must already be lazily-loaded (read + * the restrictions in the `getActorDescription` comments), + * so this is for use inside of tool. Returns a promise that + * resolves to a boolean. Must be a remote target. + * + * @param {String} actorName + * @param {String} methodName + * @return {Promise} + */ + actorHasMethod: function (actorName, methodName) { + if (!this.client) { + throw new Error("TabTarget#actorHasMethod() can only be called on " + + "remote tabs."); + } + return this.getActorDescription(actorName).then(desc => { + if (desc && desc.methods) { + return !!desc.methods.find(method => method.name === methodName); + } + return false; + }); + }, + + /** + * Returns a trait from the root actor. + * + * @param {String} traitName + * @return {Mixed} + */ + getTrait: function (traitName) { + if (!this.client) { + throw new Error("TabTarget#getTrait() can only be called on remote " + + "tabs."); + } + + // If the targeted actor exposes traits and has a defined value for this + // traits, override the root actor traits + if (this.form.traits && traitName in this.form.traits) { + return this.form.traits[traitName]; + } + + return this.client.traits[traitName]; + }, + + get tab() { + return this._tab; + }, + + get form() { + return this._form; + }, + + // Get a promise of the root form returned by a listTabs request. This promise + // is cached. + get root() { + if (!this._root) { + this._root = this._getRoot(); + } + return this._root; + }, + + _getRoot: function () { + return new Promise((resolve, reject) => { + this.client.listTabs(response => { + if (response.error) { + reject(new Error(response.error + ": " + response.message)); + return; + } + + resolve(response); + }); + }); + }, + + get client() { + return this._client; + }, + + // Tells us if we are debugging content document + // or if we are debugging chrome stuff. + // Allows to controls which features are available against + // a chrome or a content document. + get chrome() { + return this._chrome; + }, + + // Tells us if the related actor implements TabActor interface + // and requires to call `attach` request before being used + // and `detach` during cleanup + get isTabActor() { + return this._isTabActor; + }, + + get window() { + // XXX - this is a footgun for e10s - there .contentWindow will be null, + // and even though .contentWindowAsCPOW *might* work, it will not work + // in all contexts. Consumers of .window need to be refactored to not + // rely on this. + if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) { + console.error("The .window getter on devtools' |target| object isn't " + + "e10s friendly!\n" + Error().stack); + } + // Be extra careful here, since this may be called by HS_getHudByWindow + // during shutdown. + if (this._tab && this._tab.linkedBrowser) { + return this._tab.linkedBrowser.contentWindow; + } + return null; + }, + + get name() { + if (this.isAddon) { + return this._form.name; + } + return this._title; + }, + + get url() { + return this._url; + }, + + get isRemote() { + return !this.isLocalTab; + }, + + get isAddon() { + return !!(this._form && this._form.actor && ( + this._form.actor.match(/conn\d+\.addon\d+/) || + this._form.actor.match(/conn\d+\.webExtension\d+/) + )); + }, + + get isWebExtension() { + return !!(this._form && this._form.actor && + this._form.actor.match(/conn\d+\.webExtension\d+/)); + }, + + get isLocalTab() { + return !!this._tab; + }, + + get isMultiProcess() { + return !this.window; + }, + + /** + * Adds remote protocol capabilities to the target, so that it can be used + * for tools that support the Remote Debugging Protocol even for local + * connections. + */ + makeRemote: function () { + if (this._remote) { + return this._remote.promise; + } + + this._remote = defer(); + + if (this.isLocalTab) { + // Since a remote protocol connection will be made, let's start the + // DebuggerServer here, once and for all tools. + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + + this._client = new DebuggerClient(DebuggerServer.connectPipe()); + // A local TabTarget will never perform chrome debugging. + this._chrome = false; + } + + this._setupRemoteListeners(); + + let attachTab = () => { + this._client.attachTab(this._form.actor, (response, tabClient) => { + if (!tabClient) { + this._remote.reject("Unable to attach to the tab"); + return; + } + this.activeTab = tabClient; + this.threadActor = response.threadActor; + + attachConsole(); + }); + }; + + let onConsoleAttached = (response, consoleClient) => { + if (!consoleClient) { + this._remote.reject("Unable to attach to the console"); + return; + } + this.activeConsole = consoleClient; + this._remote.resolve(null); + }; + + let attachConsole = () => { + this._client.attachConsole(this._form.consoleActor, + [ "NetworkActivity" ], + onConsoleAttached); + }; + + if (this.isLocalTab) { + this._client.connect() + .then(() => this._client.getTab({ tab: this.tab })) + .then(response => { + this._form = response.tab; + this._url = this._form.url; + this._title = this._form.title; + + attachTab(); + }, e => this._remote.reject(e)); + } else if (this.isTabActor) { + // In the remote debugging case, the protocol connection will have been + // already initialized in the connection screen code. + attachTab(); + } else { + // AddonActor and chrome debugging on RootActor doesn't inherits from + // TabActor and doesn't need to be attached. + attachConsole(); + } + + return this._remote.promise; + }, + + /** + * Listen to the different events. + */ + _setupListeners: function () { + this._webProgressListener = new TabWebProgressListener(this); + this.tab.linkedBrowser.addProgressListener(this._webProgressListener); + this.tab.addEventListener("TabClose", this); + this.tab.parentNode.addEventListener("TabSelect", this); + this.tab.ownerDocument.defaultView.addEventListener("unload", this); + this.tab.addEventListener("TabRemotenessChange", this); + }, + + /** + * Teardown event listeners. + */ + _teardownListeners: function () { + if (this._webProgressListener) { + this._webProgressListener.destroy(); + } + + this._tab.ownerDocument.defaultView.removeEventListener("unload", this); + this._tab.removeEventListener("TabClose", this); + this._tab.parentNode.removeEventListener("TabSelect", this); + this._tab.removeEventListener("TabRemotenessChange", this); + }, + + /** + * Setup listeners for remote debugging, updating existing ones as necessary. + */ + _setupRemoteListeners: function () { + this.client.addListener("closed", this.destroy); + + this._onTabDetached = (aType, aPacket) => { + // We have to filter message to ensure that this detach is for this tab + if (aPacket.from == this._form.actor) { + this.destroy(); + } + }; + this.client.addListener("tabDetached", this._onTabDetached); + + this._onTabNavigated = (aType, aPacket) => { + let event = Object.create(null); + event.url = aPacket.url; + event.title = aPacket.title; + event.nativeConsoleAPI = aPacket.nativeConsoleAPI; + event.isFrameSwitching = aPacket.isFrameSwitching; + + if (!aPacket.isFrameSwitching) { + // Update the title and url unless this is a frame switch. + this._url = aPacket.url; + this._title = aPacket.title; + } + + // Send any stored event payload (DOMWindow or nsIRequest) for backwards + // compatibility with non-remotable tools. + if (aPacket.state == "start") { + event._navPayload = this._navRequest; + this.emit("will-navigate", event); + this._navRequest = null; + } else { + event._navPayload = this._navWindow; + this.emit("navigate", event); + this._navWindow = null; + } + }; + this.client.addListener("tabNavigated", this._onTabNavigated); + + this._onFrameUpdate = (aType, aPacket) => { + this.emit("frame-update", aPacket); + }; + this.client.addListener("frameUpdate", this._onFrameUpdate); + + this._onSourceUpdated = (event, packet) => this.emit("source-updated", packet); + this.client.addListener("newSource", this._onSourceUpdated); + this.client.addListener("updatedSource", this._onSourceUpdated); + }, + + /** + * Teardown listeners for remote debugging. + */ + _teardownRemoteListeners: function () { + this.client.removeListener("closed", this.destroy); + this.client.removeListener("tabNavigated", this._onTabNavigated); + this.client.removeListener("tabDetached", this._onTabDetached); + this.client.removeListener("frameUpdate", this._onFrameUpdate); + this.client.removeListener("newSource", this._onSourceUpdated); + this.client.removeListener("updatedSource", this._onSourceUpdated); + }, + + /** + * Handle tabs events. + */ + handleEvent: function (event) { + switch (event.type) { + case "TabClose": + case "unload": + this.destroy(); + break; + case "TabSelect": + if (this.tab.selected) { + this.emit("visible", event); + } else { + this.emit("hidden", event); + } + break; + case "TabRemotenessChange": + this.onRemotenessChange(); + break; + } + }, + + // Automatically respawn the toolbox when the tab changes between being + // loaded within the parent process and loaded from a content process. + // Process change can go in both ways. + onRemotenessChange: function () { + // Responsive design do a crazy dance around tabs and triggers + // remotenesschange events. But we should ignore them as at the end + // the content doesn't change its remoteness. + if (this._tab.isResponsiveDesignMode) { + return; + } + + // Save a reference to the tab as it will be nullified on destroy + let tab = this._tab; + let onToolboxDestroyed = (event, target) => { + if (target != this) { + return; + } + gDevTools.off("toolbox-destroyed", target); + + // Recreate a fresh target instance as the current one is now destroyed + let newTarget = TargetFactory.forTab(tab); + gDevTools.showToolbox(newTarget); + }; + gDevTools.on("toolbox-destroyed", onToolboxDestroyed); + }, + + /** + * Target is not alive anymore. + */ + destroy: function () { + // 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.promise; + } + + this._destroyer = defer(); + + // Before taking any action, notify listeners that destruction is imminent. + this.emit("close"); + + if (this._tab) { + this._teardownListeners(); + } + + let cleanupAndResolve = () => { + this._cleanup(); + this._destroyer.resolve(null); + }; + // If this target was not remoted, the promise will be resolved before the + // function returns. + if (this._tab && !this._client) { + cleanupAndResolve(); + } else if (this._client) { + // If, on the other hand, this target was remoted, the promise will be + // resolved after the remote connection is closed. + this._teardownRemoteListeners(); + + if (this.isLocalTab) { + // We started with a local tab and created the client ourselves, so we + // should close it. + this._client.close().then(cleanupAndResolve); + } else if (this.activeTab) { + // The client was handed to us, so we are not responsible for closing + // it. We just need to detach from the tab, if already attached. + // |detach| may fail if the connection is already dead, so proceed with + // cleanup directly after this. + this.activeTab.detach(); + cleanupAndResolve(); + } else { + cleanupAndResolve(); + } + } + + return this._destroyer.promise; + }, + + /** + * Clean up references to what this target points to. + */ + _cleanup: function () { + if (this._tab) { + targets.delete(this._tab); + } else { + promiseTargets.delete(this._form); + } + + this.activeTab = null; + this.activeConsole = null; + this._client = null; + this._tab = null; + this._form = null; + this._remote = null; + this._root = null; + this._title = null; + this._url = null; + this.threadActor = null; + }, + + toString: function () { + let id = this._tab ? this._tab : (this._form && this._form.actor); + return `TabTarget:${id}`; + }, + + /** + * @see TabActor.prototype.onResolveLocation + */ + resolveLocation(loc) { + let deferred = defer(); + + this.client.request(Object.assign({ + to: this._form.actor, + type: "resolveLocation", + }, loc), deferred.resolve); + + return deferred.promise; + }, +}; + +/** + * WebProgressListener for TabTarget. + * + * @param object aTarget + * The TabTarget instance to work with. + */ +function TabWebProgressListener(aTarget) { + this.target = aTarget; +} + +TabWebProgressListener.prototype = { + target: null, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference]), + + onStateChange: function (progress, request, flag) { + let isStart = flag & Ci.nsIWebProgressListener.STATE_START; + let isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT; + let isNetwork = flag & Ci.nsIWebProgressListener.STATE_IS_NETWORK; + let isRequest = flag & Ci.nsIWebProgressListener.STATE_IS_REQUEST; + + // Skip non-interesting states. + if (!isStart || !isDocument || !isRequest || !isNetwork) { + return; + } + + // emit event if the top frame is navigating + if (progress.isTopLevel) { + // Emit the event if the target is not remoted or store the payload for + // later emission otherwise. + if (this.target._client) { + this.target._navRequest = request; + } else { + this.target.emit("will-navigate", request); + } + } + }, + + onProgressChange: function () {}, + onSecurityChange: function () {}, + onStatusChange: function () {}, + + onLocationChange: function (webProgress, request, URI, flags) { + if (this.target && + !(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)) { + let window = webProgress.DOMWindow; + // Emit the event if the target is not remoted or store the payload for + // later emission otherwise. + if (this.target._client) { + this.target._navWindow = window; + } else { + this.target.emit("navigate", window); + } + } + }, + + /** + * Destroy the progress listener instance. + */ + destroy: function () { + if (this.target.tab) { + try { + this.target.tab.linkedBrowser.removeProgressListener(this); + } catch (ex) { + // This can throw when a tab crashes in e10s. + } + } + this.target._webProgressListener = null; + this.target._navRequest = null; + this.target._navWindow = null; + this.target = null; + } +}; + +function WorkerTarget(workerClient) { + EventEmitter.decorate(this); + this._workerClient = workerClient; +} + +/** + * A WorkerTarget represents a worker. Unlike TabTarget, which can represent + * either a local or remote tab, WorkerTarget always represents a remote worker. + * Moreover, unlike TabTarget, which is constructed with a placeholder object + * for remote tabs (from which a TabClient can then be lazily obtained), + * WorkerTarget is constructed with a WorkerClient directly. + * + * WorkerClient is designed to mimic the interface of TabClient as closely as + * possible. This allows us to debug workers as if they were ordinary tabs, + * requiring only minimal changes to the rest of the frontend. + */ +WorkerTarget.prototype = { + get isRemote() { + return true; + }, + + get isTabActor() { + return true; + }, + + get name() { + return "Worker"; + }, + + get url() { + return this._workerClient.url; + }, + + get isWorkerTarget() { + return true; + }, + + get form() { + return { + consoleActor: this._workerClient.consoleActor + }; + }, + + get activeTab() { + return this._workerClient; + }, + + get client() { + return this._workerClient.client; + }, + + destroy: function () { + this._workerClient.detach(); + }, + + hasActor: function (name) { + // console is the only one actor implemented by WorkerActor + if (name == "console") { + return true; + } + return false; + }, + + getTrait: function () { + return undefined; + }, + + makeRemote: function () { + return Promise.resolve(); + } +}; diff --git a/devtools/client/framework/test/.eslintrc.js b/devtools/client/framework/test/.eslintrc.js new file mode 100644 index 000000000..8d15a76d9 --- /dev/null +++ b/devtools/client/framework/test/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + "extends": "../../../.eslintrc.mochitests.js" +}; diff --git a/devtools/client/framework/test/browser.ini b/devtools/client/framework/test/browser.ini new file mode 100644 index 000000000..f34cd66f0 --- /dev/null +++ b/devtools/client/framework/test/browser.ini @@ -0,0 +1,95 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + browser_toolbox_options_disable_js.html + browser_toolbox_options_disable_js_iframe.html + browser_toolbox_options_disable_cache.sjs + browser_toolbox_sidebar_tool.xul + browser_toolbox_window_title_changes_page.html + browser_toolbox_window_title_frame_select_page.html + code_binary_search.coffee + code_binary_search.js + code_binary_search.map + code_math.js + code_ugly.js + doc_empty-tab-01.html + head.js + shared-head.js + shared-redux-head.js + helper_disable_cache.js + doc_theme.css + doc_viewsource.html + browser_toolbox_options_enable_serviceworkers_testing_frame_script.js + browser_toolbox_options_enable_serviceworkers_testing.html + serviceworker.js + +[browser_browser_toolbox.js] +[browser_browser_toolbox_debugger.js] +[browser_devtools_api.js] +[browser_devtools_api_destroy.js] +[browser_dynamic_tool_enabling.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-02.js] +[browser_target_from_url.js] +[browser_target_events.js] +[browser_target_remote.js] +[browser_target_support.js] +[browser_toolbox_custom_host.js] +[browser_toolbox_dynamic_registration.js] +[browser_toolbox_getpanelwhenready.js] +[browser_toolbox_highlight.js] +[browser_toolbox_hosts.js] +[browser_toolbox_hosts_size.js] +[browser_toolbox_hosts_telemetry.js] +[browser_toolbox_keyboard_navigation.js] +skip-if = os == "mac" # Full keyboard navigation on OSX only works if Full Keyboard Access setting is set to All Control in System Keyboard Preferences +[browser_toolbox_minimize.js] +skip-if = true # Bug 1177463 - Temporarily hide the minimize button +[browser_toolbox_options.js] +[browser_toolbox_options_disable_buttons.js] +[browser_toolbox_options_disable_cache-01.js] +[browser_toolbox_options_disable_cache-02.js] +[browser_toolbox_options_disable_js.js] +[browser_toolbox_options_enable_serviceworkers_testing.js] +# [browser_toolbox_raise.js] # Bug 962258 +# skip-if = os == "win" +[browser_toolbox_races.js] +[browser_toolbox_ready.js] +[browser_toolbox_remoteness_change.js] +run-if = e10s +[browser_toolbox_select_event.js] +skip-if = e10s # Bug 1069044 - destroyInspector may hang during shutdown +[browser_toolbox_selected_tool_unavailable.js] +[browser_toolbox_sidebar.js] +[browser_toolbox_sidebar_events.js] +[browser_toolbox_sidebar_existing_tabs.js] +[browser_toolbox_sidebar_overflow_menu.js] +[browser_toolbox_split_console.js] +[browser_toolbox_target.js] +[browser_toolbox_tabsswitch_shortcuts.js] +[browser_toolbox_textbox_context_menu.js] +[browser_toolbox_theme_registration.js] +[browser_toolbox_toggle.js] +[browser_toolbox_tool_ready.js] +[browser_toolbox_tool_remote_reopen.js] +[browser_toolbox_transport_events.js] +[browser_toolbox_view_source_01.js] +[browser_toolbox_view_source_02.js] +[browser_toolbox_view_source_03.js] +[browser_toolbox_view_source_04.js] +[browser_toolbox_window_reload_target.js] +[browser_toolbox_window_shortcuts.js] +skip-if = os == "mac" && os_version == "10.8" || os == "win" && os_version == "5.1" # Bug 851129 - Re-enable browser_toolbox_window_shortcuts.js test after leaks are fixed +[browser_toolbox_window_title_changes.js] +[browser_toolbox_window_title_frame_select.js] +[browser_toolbox_zoom.js] +[browser_two_tabs.js] +# We want this test to run for mochitest-dt as well, so we include it here: +[../../../../browser/base/content/test/general/browser_parsable_css.js] diff --git a/devtools/client/framework/test/browser_browser_toolbox.js b/devtools/client/framework/test/browser_browser_toolbox.js new file mode 100644 index 000000000..08c8ac190 --- /dev/null +++ b/devtools/client/framework/test/browser_browser_toolbox.js @@ -0,0 +1,65 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// On debug test slave, it takes about 50s to run the test. +requestLongerTimeout(4); + +add_task(function* runTest() { + yield new Promise(done => { + let options = {"set": [ + ["devtools.debugger.prompt-connection", false], + ["devtools.debugger.remote-enabled", true], + ["devtools.chrome.enabled", true], + // Test-only pref to allow passing `testScript` argument to the browser + // toolbox + ["devtools.browser-toolbox.allow-unsafe-script", true], + // On debug test slave, it takes more than the default time (20s) + // to get a initialized console + ["devtools.debugger.remote-timeout", 120000] + ]}; + SpecialPowers.pushPrefEnv(options, done); + }); + + // Wait for a notification sent by a script evaluated in the webconsole + // of the browser toolbox. + let onCustomMessage = new Promise(done => { + Services.obs.addObserver(function listener() { + Services.obs.removeObserver(listener, "browser-toolbox-console-works"); + done(); + }, "browser-toolbox-console-works", false); + }); + + // Be careful, this JS function is going to be executed in the addon toolbox, + // which lives in another process. So do not try to use any scope variable! + let env = Components.classes["@mozilla.org/process/environment;1"].getService(Components.interfaces.nsIEnvironment); + let testScript = function () { + toolbox.selectTool("webconsole") + .then(console => { + let { jsterm } = console.hud; + let js = "Services.obs.notifyObservers(null, 'browser-toolbox-console-works', null);"; + return jsterm.execute(js); + }) + .then(() => toolbox.destroy()); + }; + env.set("MOZ_TOOLBOX_TEST_SCRIPT", "new " + testScript); + registerCleanupFunction(() => { + env.set("MOZ_TOOLBOX_TEST_SCRIPT", ""); + }); + + let { BrowserToolboxProcess } = Cu.import("resource://devtools/client/framework/ToolboxProcess.jsm", {}); + let closePromise; + yield new Promise(onRun => { + closePromise = new Promise(onClose => { + info("Opening the browser toolbox\n"); + BrowserToolboxProcess.init(onClose, onRun); + }); + }); + ok(true, "Browser toolbox started\n"); + + yield onCustomMessage; + ok(true, "Received the custom message"); + + yield closePromise; + ok(true, "Browser toolbox process just closed"); +}); diff --git a/devtools/client/framework/test/browser_browser_toolbox_debugger.js b/devtools/client/framework/test/browser_browser_toolbox_debugger.js new file mode 100644 index 000000000..c0971cc7c --- /dev/null +++ b/devtools/client/framework/test/browser_browser_toolbox_debugger.js @@ -0,0 +1,131 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// On debug test runner, it takes about 50s to run the test. +requestLongerTimeout(4); + +const { setInterval, clearInterval } = require("sdk/timers"); + +add_task(function* runTest() { + yield new Promise(done => { + let options = {"set": [ + ["devtools.debugger.prompt-connection", false], + ["devtools.debugger.remote-enabled", true], + ["devtools.chrome.enabled", true], + // Test-only pref to allow passing `testScript` argument to the browser + // toolbox + ["devtools.browser-toolbox.allow-unsafe-script", true], + // On debug test runner, it takes more than the default time (20s) + // to get a initialized console + ["devtools.debugger.remote-timeout", 120000] + ]}; + SpecialPowers.pushPrefEnv(options, done); + }); + + let s = Cu.Sandbox("http://mozilla.org"); + // 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. + let testUrl = "http://mozilla.org/browser-toolbox-test.js"; + Cu.evalInSandbox("(" + function () { + this.plop = function plop() { + return 1; + }; + } + ").call(this)", s, "1.8", testUrl, 0); + + // Execute the function every second in order to trigger the breakpoint + let interval = setInterval(s.plop, 1000); + + // Be careful, this JS function is going to be executed in the browser toolbox, + // which lives in another process. So do not try to use any scope variable! + let env = Components.classes["@mozilla.org/process/environment;1"] + .getService(Components.interfaces.nsIEnvironment); + let testScript = function () { + const { Task } = Components.utils.import("resource://gre/modules/Task.jsm", {}); + dump("Opening the browser toolbox and debugger panel\n"); + let window, document; + let testUrl = "http://mozilla.org/browser-toolbox-test.js"; + Task.spawn(function* () { + dump("Waiting for debugger load\n"); + let panel = yield toolbox.selectTool("jsdebugger"); + let window = panel.panelWin; + let document = window.document; + + yield window.once(window.EVENTS.SOURCE_SHOWN); + + dump("Loaded, selecting the test script to debug\n"); + let item = document.querySelector(`.dbg-source-item[tooltiptext="${testUrl}"]`); + let onSourceShown = window.once(window.EVENTS.SOURCE_SHOWN); + item.click(); + yield onSourceShown; + + dump("Selected, setting a breakpoint\n"); + let { Sources, editor } = window.DebuggerView; + let onBreak = window.once(window.EVENTS.FETCHED_SCOPES); + editor.emit("gutterClick", 1); + yield onBreak; + + dump("Paused, asserting breakpoint position\n"); + let url = Sources.selectedItem.attachment.source.url; + if (url != testUrl) { + throw new Error("Breaking on unexpected script: " + url); + } + let cursor = editor.getCursor(); + if (cursor.line != 1) { + throw new Error("Breaking on unexpected line: " + cursor.line); + } + + dump("Now, stepping over\n"); + let stepOver = window.document.querySelector("#step-over"); + let onFetchedScopes = window.once(window.EVENTS.FETCHED_SCOPES); + stepOver.click(); + yield onFetchedScopes; + + dump("Stepped, asserting step position\n"); + url = Sources.selectedItem.attachment.source.url; + if (url != testUrl) { + throw new Error("Stepping on unexpected script: " + url); + } + cursor = editor.getCursor(); + if (cursor.line != 2) { + throw new Error("Stepping on unexpected line: " + cursor.line); + } + + dump("Resume script execution\n"); + let resume = window.document.querySelector("#resume"); + let onResume = toolbox.target.once("thread-resumed"); + resume.click(); + yield onResume; + + dump("Close the browser toolbox\n"); + toolbox.destroy(); + + }).catch(error => { + dump("Error while running code in the browser toolbox process:\n"); + dump(error + "\n"); + dump("stack:\n" + error.stack + "\n"); + }); + }; + env.set("MOZ_TOOLBOX_TEST_SCRIPT", "new " + testScript); + registerCleanupFunction(() => { + env.set("MOZ_TOOLBOX_TEST_SCRIPT", ""); + }); + + let { BrowserToolboxProcess } = Cu.import("resource://devtools/client/framework/ToolboxProcess.jsm", {}); + // Use two promises, one for each BrowserToolboxProcess.init callback + // arguments, to ensure that we wait for toolbox run and close events. + let closePromise; + yield new Promise(onRun => { + closePromise = new Promise(onClose => { + info("Opening the browser toolbox\n"); + BrowserToolboxProcess.init(onClose, onRun); + }); + }); + ok(true, "Browser toolbox started\n"); + + yield closePromise; + ok(true, "Browser toolbox process just closed"); + + clearInterval(interval); +}); diff --git a/devtools/client/framework/test/browser_devtools_api.js b/devtools/client/framework/test/browser_devtools_api.js new file mode 100644 index 000000000..72d415c0b --- /dev/null +++ b/devtools/client/framework/test/browser_devtools_api.js @@ -0,0 +1,264 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// +// Whitelisting this test. +// As part of bug 1077403, the leaking uncaught rejections should be fixed. +// +thisTestLeaksUncaughtRejectionsAndShouldBeFixed("TypeError: this.docShell is null"); + +// When running in a standalone directory, we get this error +thisTestLeaksUncaughtRejectionsAndShouldBeFixed("TypeError: this.doc is undefined"); + +// Tests devtools API + +const toolId1 = "test-tool-1"; +const toolId2 = "test-tool-2"; + +var EventEmitter = require("devtools/shared/event-emitter"); + +function test() { + addTab("about:blank").then(runTests1); +} + +// Test scenario 1: the tool definition build method returns a promise. +function runTests1(aTab) { + let toolDefinition = { + id: toolId1, + isTargetSupported: () => true, + visibilityswitch: "devtools.test-tool.enabled", + url: "about:blank", + label: "someLabel", + build: function (iframeWindow, toolbox) { + let panel = new DevToolPanel(iframeWindow, toolbox); + return panel.open(); + }, + }; + + ok(gDevTools, "gDevTools exists"); + ok(!gDevTools.getToolDefinitionMap().has(toolId1), + "The tool is not registered"); + + gDevTools.registerTool(toolDefinition); + ok(gDevTools.getToolDefinitionMap().has(toolId1), + "The tool is registered"); + + let target = TargetFactory.forTab(gBrowser.selectedTab); + + let events = {}; + + // Check events on the gDevTools and toolbox objects. + gDevTools.once(toolId1 + "-init", (event, toolbox, iframe) => { + ok(iframe, "iframe argument available"); + + toolbox.once(toolId1 + "-init", (event, iframe) => { + ok(iframe, "iframe argument available"); + events["init"] = true; + }); + }); + + gDevTools.once(toolId1 + "-ready", (event, toolbox, panel) => { + ok(panel, "panel argument available"); + + toolbox.once(toolId1 + "-ready", (event, panel) => { + ok(panel, "panel argument available"); + events["ready"] = true; + }); + }); + + gDevTools.showToolbox(target, toolId1).then(function (toolbox) { + is(toolbox.target, target, "toolbox target is correct"); + is(toolbox.target.tab, gBrowser.selectedTab, "targeted tab is correct"); + + ok(events["init"], "init event fired"); + ok(events["ready"], "ready event fired"); + + gDevTools.unregisterTool(toolId1); + + // Wait for unregisterTool to select the next tool before calling runTests2, + // otherwise we will receive the wrong select event when waiting for + // unregisterTool to select the next tool in continueTests below. + toolbox.once("select", runTests2); + }); +} + +// Test scenario 2: the tool definition build method returns panel instance. +function runTests2() { + let toolDefinition = { + id: toolId2, + isTargetSupported: () => true, + visibilityswitch: "devtools.test-tool.enabled", + url: "about:blank", + label: "someLabel", + build: function (iframeWindow, toolbox) { + return new DevToolPanel(iframeWindow, toolbox); + }, + }; + + ok(!gDevTools.getToolDefinitionMap().has(toolId2), + "The tool is not registered"); + + gDevTools.registerTool(toolDefinition); + ok(gDevTools.getToolDefinitionMap().has(toolId2), + "The tool is registered"); + + let target = TargetFactory.forTab(gBrowser.selectedTab); + + let events = {}; + + // Check events on the gDevTools and toolbox objects. + gDevTools.once(toolId2 + "-init", (event, toolbox, iframe) => { + ok(iframe, "iframe argument available"); + + toolbox.once(toolId2 + "-init", (event, iframe) => { + ok(iframe, "iframe argument available"); + events["init"] = true; + }); + }); + + gDevTools.once(toolId2 + "-build", (event, toolbox, panel, iframe) => { + ok(panel, "panel argument available"); + + toolbox.once(toolId2 + "-build", (event, panel, iframe) => { + ok(panel, "panel argument available"); + events["build"] = true; + }); + }); + + gDevTools.once(toolId2 + "-ready", (event, toolbox, panel) => { + ok(panel, "panel argument available"); + + toolbox.once(toolId2 + "-ready", (event, panel) => { + ok(panel, "panel argument available"); + events["ready"] = true; + }); + }); + + gDevTools.showToolbox(target, toolId2).then(function (toolbox) { + is(toolbox.target, target, "toolbox target is correct"); + is(toolbox.target.tab, gBrowser.selectedTab, "targeted tab is correct"); + + ok(events["init"], "init event fired"); + ok(events["build"], "build event fired"); + ok(events["ready"], "ready event fired"); + + continueTests(toolbox); + }); +} + +var continueTests = Task.async(function* (toolbox, panel) { + ok(toolbox.getCurrentPanel(), "panel value is correct"); + is(toolbox.currentToolId, toolId2, "toolbox _currentToolId is correct"); + + ok(!toolbox.doc.getElementById("toolbox-tab-" + toolId2).hasAttribute("icon-invertable"), + "The tool tab does not have the invertable attribute"); + + ok(toolbox.doc.getElementById("toolbox-tab-inspector").hasAttribute("icon-invertable"), + "The builtin tool tabs do have the invertable attribute"); + + let toolDefinitions = gDevTools.getToolDefinitionMap(); + ok(toolDefinitions.has(toolId2), "The tool is in gDevTools"); + + let toolDefinition = toolDefinitions.get(toolId2); + is(toolDefinition.id, toolId2, "toolDefinition id is correct"); + + info("Testing toolbox tool-unregistered event"); + let toolSelected = toolbox.once("select"); + let unregisteredTool = yield new Promise(resolve => { + toolbox.once("tool-unregistered", (e, id) => resolve(id)); + gDevTools.unregisterTool(toolId2); + }); + yield toolSelected; + + is(unregisteredTool, toolId2, "Event returns correct id"); + ok(!toolbox.isToolRegistered(toolId2), + "Toolbox: The tool is not registered"); + ok(!gDevTools.getToolDefinitionMap().has(toolId2), + "The tool is no longer registered"); + + info("Testing toolbox tool-registered event"); + let registeredTool = yield new Promise(resolve => { + toolbox.once("tool-registered", (e, id) => resolve(id)); + gDevTools.registerTool(toolDefinition); + }); + + is(registeredTool, toolId2, "Event returns correct id"); + ok(toolbox.isToolRegistered(toolId2), + "Toolbox: The tool is registered"); + ok(gDevTools.getToolDefinitionMap().has(toolId2), + "The tool is registered"); + + info("Unregistering tool"); + gDevTools.unregisterTool(toolId2); + + destroyToolbox(toolbox); +}); + +function destroyToolbox(toolbox) { + toolbox.destroy().then(function () { + let target = TargetFactory.forTab(gBrowser.selectedTab); + ok(gDevTools._toolboxes.get(target) == null, "gDevTools doesn't know about target"); + ok(toolbox.target == null, "toolbox doesn't know about target."); + finishUp(); + }); +} + +function finishUp() { + gBrowser.removeCurrentTab(); + finish(); +} + +/** +* 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; + + /* let doc = iframeWindow.document + let label = doc.createElement("label"); + let textNode = doc.createTextNode("Some Tool"); + + label.appendChild(textNode); + doc.body.appendChild(label);*/ +} + +DevToolPanel.prototype = { + open: function () { + let deferred = defer(); + + executeSoon(() => { + this._isReady = true; + this.emit("ready"); + deferred.resolve(this); + }); + + return deferred.promise; + }, + + get target() { + return this._toolbox.target; + }, + + get toolbox() { + return this._toolbox; + }, + + get isReady() { + return this._isReady; + }, + + _isReady: false, + + destroy: function DTI_destroy() { + return defer(null); + }, +}; 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 000000000..084a7a0a1 --- /dev/null +++ b/devtools/client/framework/test/browser_devtools_api_destroy.js @@ -0,0 +1,71 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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); +} + +function runTests(aTab) { + let toolDefinition = { + id: "testTool", + visibilityswitch: "devtools.testTool.enabled", + isTargetSupported: () => true, + url: "about:blank", + label: "someLabel", + build: function (iframeWindow, toolbox) { + let deferred = defer(); + executeSoon(() => { + deferred.resolve({ + target: toolbox.target, + toolbox: toolbox, + isReady: true, + destroy: function () {}, + }); + }); + return deferred.promise; + }, + }; + + gDevTools.registerTool(toolDefinition); + + let collectedEvents = []; + + let target = TargetFactory.forTab(aTab); + gDevTools.showToolbox(target, toolDefinition.id).then(function (toolbox) { + let panel = toolbox.getPanel(toolDefinition.id); + ok(panel, "Tool open"); + + gDevTools.once("toolbox-destroy", (event, toolbox, iframe) => { + collectedEvents.push(event); + }); + + gDevTools.once(toolDefinition.id + "-destroy", (event, toolbox, iframe) => { + collectedEvents.push("gDevTools-" + event); + }); + + toolbox.once("destroy", (event) => { + collectedEvents.push(event); + }); + + toolbox.once(toolDefinition.id + "-destroy", (event) => { + collectedEvents.push("toolbox-" + event); + }); + + 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 000000000..6420afabe --- /dev/null +++ b/devtools/client/framework/test/browser_dynamic_tool_enabling.js @@ -0,0 +1,41 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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_devToolbar": "devtools.toolbar.enabled", + "menu_browserToolbox": ["devtools.chrome.enabled", "devtools.debugger.remote-enabled"], + "menu_devtools_connect": "devtools.debugger.remote-enabled", +}; + +function expectedAttributeValueFromPrefs(prefs) { + return prefs.every((pref) => Services.prefs.getBoolPref(pref)) ? + "" : "true"; +} + +function checkItem(el, prefs) { + let 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 (let k in gItemsToTest) { + let el = document.getElementById(k); + let prefs = gItemsToTest[k]; + if (typeof prefs == "string") { + prefs = [prefs]; + } + checkItem(el, prefs); + for (let 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_ignore_toolbox_network_requests.js b/devtools/client/framework/test/browser_ignore_toolbox_network_requests.js new file mode 100644 index 000000000..1cfc22f7e --- /dev/null +++ b/devtools/client/framework/test/browser_ignore_toolbox_network_requests.js @@ -0,0 +1,33 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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(function* () { + // TODO: This test tries to verify the normal behavior of the netmonitor and + // therefore needs to avoid the explicit check for tests. Bug 1167188 will + // allow us to remove this workaround. + let isTesting = flags.testing; + flags.testing = false; + + let tab = yield addTab(URL_ROOT + "doc_viewsource.html"); + let target = TargetFactory.forTab(tab); + let toolbox = yield gDevTools.showToolbox(target, "styleeditor"); + let panel = toolbox.getPanel("styleeditor"); + + is(panel.UI.editors.length, 1, "correct number of editors opened"); + + let monitor = yield toolbox.selectTool("netmonitor"); + let { RequestsMenu } = monitor.panelWin.NetMonitorView; + is(RequestsMenu.itemCount, 0, "No network requests appear in the network panel"); + + yield gDevTools.closeToolbox(target); + tab = target = toolbox = panel = null; + gBrowser.removeCurrentTab(); + flags.testing = isTesting; +}); 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 000000000..4e4effb07 --- /dev/null +++ b/devtools/client/framework/test/browser_keybindings_01.js @@ -0,0 +1,115 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// 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>" +function test() +{ + waitForExplicitFinish(); + + let doc; + let node; + let inspector; + let keysetMap = { }; + + addTab(TEST_URL).then(function () { + doc = content.document; + node = doc.querySelector("h1"); + waitForFocus(setupKeyBindingsTest); + }); + + function buildDevtoolsKeysetMap(keyset) { + [].forEach.call(keyset.querySelectorAll("key"), function (key) { + + if (!key.getAttribute("key")) { + return; + } + + let modifiers = key.getAttribute("modifiers"); + + keysetMap[key.id.split("_")[1]] = { + key: key.getAttribute("key"), + modifiers: modifiers, + modifierOpt: { + shiftKey: modifiers.match("shift"), + ctrlKey: modifiers.match("ctrl"), + altKey: modifiers.match("alt"), + metaKey: modifiers.match("meta"), + accelKey: modifiers.match("accel") + }, + synthesizeKey: function () { + EventUtils.synthesizeKey(this.key, this.modifierOpt); + } + }; + }); + } + + function setupKeyBindingsTest() + { + for (let win of gDevToolsBrowser._trackedBrowserWindows) { + buildDevtoolsKeysetMap(win.document.getElementById("devtoolsKeyset")); + } + + gDevTools.once("toolbox-ready", (e, toolbox) => { + inspectorShouldBeOpenAndHighlighting(toolbox.getCurrentPanel(), toolbox); + }); + + keysetMap.inspector.synthesizeKey(); + } + + function inspectorShouldBeOpenAndHighlighting(aInspector, aToolbox) + { + is(aToolbox.currentToolId, "inspector", "Correct tool has been loaded"); + + aToolbox.once("picker-started", () => { + ok(true, "picker-started event received, highlighter started"); + keysetMap.inspector.synthesizeKey(); + + aToolbox.once("picker-stopped", () => { + ok(true, "picker-stopped event received, highlighter stopped"); + gDevTools.once("select-tool-command", () => { + webconsoleShouldBeSelected(aToolbox); + }); + keysetMap.webconsole.synthesizeKey(); + }); + }); + } + + function webconsoleShouldBeSelected(aToolbox) + { + is(aToolbox.currentToolId, "webconsole", "webconsole should be selected."); + + gDevTools.once("select-tool-command", () => { + jsdebuggerShouldBeSelected(aToolbox); + }); + keysetMap.jsdebugger.synthesizeKey(); + } + + function jsdebuggerShouldBeSelected(aToolbox) + { + is(aToolbox.currentToolId, "jsdebugger", "jsdebugger should be selected."); + + gDevTools.once("select-tool-command", () => { + netmonitorShouldBeSelected(aToolbox); + }); + + keysetMap.netmonitor.synthesizeKey(); + } + + function netmonitorShouldBeSelected(aToolbox, panel) + { + is(aToolbox.currentToolId, "netmonitor", "netmonitor should be selected."); + finishUp(); + } + + function finishUp() { + doc = node = inspector = keysetMap = null; + gBrowser.removeCurrentTab(); + finish(); + } +} 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 000000000..551fef873 --- /dev/null +++ b/devtools/client/framework/test/browser_keybindings_02.js @@ -0,0 +1,65 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("devtools/client/framework/toolbox"); + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties"); + +function getZoomValue() { + return parseFloat(Services.prefs.getCharPref("devtools.toolbox.zoomValue")); +} + +add_task(function* () { + info("Create a test tab and open the toolbox"); + let tab = yield addTab(URL); + let target = TargetFactory.forTab(tab); + let toolbox = yield gDevTools.showToolbox(target, "webconsole"); + + let {SIDE, BOTTOM} = Toolbox.HostType; + for (let type of [SIDE, BOTTOM, SIDE]) { + info("Switch to host type " + type); + yield toolbox.switchHost(type); + + info("Try to use the toolbox shortcuts"); + yield checkKeyBindings(toolbox); + } + + Services.prefs.clearUserPref("devtools.toolbox.zoomValue"); + Services.prefs.setCharPref("devtools.toolbox.host", BOTTOM); + yield toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +function zoomWithKey(toolbox, key) { + let shortcut = L10N.getStr(key); + if (!shortcut) { + info("Key was empty, skipping zoomWithKey"); + return; + } + info("Zooming with key: " + key); + let 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.zoomIn3.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 000000000..752087a09 --- /dev/null +++ b/devtools/client/framework/test/browser_keybindings_03.js @@ -0,0 +1,53 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("devtools/client/framework/toolbox"); + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties"); + +add_task(function* () { + info("Create a test tab and open the toolbox"); + let tab = yield addTab(URL); + let target = TargetFactory.forTab(tab); + let toolbox = yield gDevTools.showToolbox(target, "webconsole"); + + let shortcut = L10N.getStr("toolbox.toggleHost.key"); + + let {SIDE, BOTTOM, WINDOW} = Toolbox.HostType; + checkHostType(toolbox, BOTTOM, SIDE); + + info("Switching from bottom to side"); + let onHostChanged = toolbox.once("host-changed"); + synthesizeKeyShortcut(shortcut, toolbox.win); + yield onHostChanged; + checkHostType(toolbox, SIDE, BOTTOM); + + info("Switching from side to bottom"); + onHostChanged = toolbox.once("host-changed"); + synthesizeKeyShortcut(shortcut, toolbox.win); + yield onHostChanged; + checkHostType(toolbox, BOTTOM, SIDE); + + info("Switching to window"); + yield toolbox.switchHost(WINDOW); + checkHostType(toolbox, WINDOW, BOTTOM); + + info("Switching from window to bottom"); + onHostChanged = toolbox.once("host-changed"); + synthesizeKeyShortcut(shortcut, toolbox.win); + yield onHostChanged; + checkHostType(toolbox, BOTTOM, WINDOW); + + yield 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 000000000..cf634ff6f --- /dev/null +++ b/devtools/client/framework/test/browser_menu_api.js @@ -0,0 +1,181 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("devtools/client/framework/menu"); +const MenuItem = require("devtools/client/framework/menu-item"); + +add_task(function* () { + info("Create a test tab and open the toolbox"); + let tab = yield addTab(URL); + let target = TargetFactory.forTab(tab); + let toolbox = yield gDevTools.showToolbox(target, "webconsole"); + + yield testMenuItems(); + yield testMenuPopup(toolbox); + yield testSubmenu(toolbox); +}); + +function* testMenuItems() { + let menu = new Menu(); + let menuItem1 = new MenuItem(); + let 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"); +} + +function* testMenuPopup(toolbox) { + let clickFired = false; + + let menu = new Menu({ + id: "menu-popup", + }); + menu.append(new MenuItem({ type: "separator" })); + + let 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, + }), + ]; + + for (let 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); + + ok(toolbox.doc.querySelector("#menu-popup"), "A popup is in the DOM"); + + let menuSeparators = + toolbox.doc.querySelectorAll("#menu-popup > menuseparator"); + is(menuSeparators.length, 1, "A separator is in the menu"); + + let menuItems = toolbox.doc.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"); + + yield once(menu, "open"); + let closed = once(menu, "close"); + EventUtils.synthesizeMouseAtCenter(menuItems[0], {}, toolbox.win); + yield closed; + ok(clickFired, "Click has fired"); + + ok(!toolbox.doc.querySelector("#menu-popup"), "Popup removed from the DOM"); +} + +function* testSubmenu(toolbox) { + let clickFired = false; + let menu = new Menu({ + id: "menu-popup", + }); + let 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({ + label: "Submenu parent", + submenu: submenu, + })); + menu.append(new MenuItem({ + label: "Submenu parent with attributes", + id: "submenu-parent-with-attrs", + submenu: submenu, + accesskey: "A", + disabled: true, + })); + + menu.popup(0, 0, toolbox); + ok(toolbox.doc.querySelector("#menu-popup"), "A popup is in the DOM"); + is(toolbox.doc.querySelectorAll("#menu-popup > menuitem").length, 0, + "No menuitem children"); + + let menus = toolbox.doc.querySelectorAll("#menu-popup > menu"); + is(menus.length, 2, "Correct number of menus"); + is(menus[0].getAttribute("label"), "Submenu parent", "Correct label"); + ok(!menus[0].hasAttribute("disabled"), "Correct disabled state"); + + is(menus[1].getAttribute("accesskey"), "A", "Correct accesskey"); + ok(menus[1].hasAttribute("disabled"), "Correct disabled state"); + ok(menus[1].id, "submenu-parent-with-attrs", "Correct id"); + + let subMenuItems = menus[0].querySelectorAll("menupopup > menuitem"); + is(subMenuItems.length, 1, "Correct number of submenu items"); + is(subMenuItems[0].getAttribute("label"), "Submenu item", "Correct label"); + + yield once(menu, "open"); + let closed = once(menu, "close"); + + info("Using keyboard navigation to open, close, and reopen the submenu"); + let shown = once(menus[0], "popupshown"); + EventUtils.synthesizeKey("VK_DOWN", {}); + EventUtils.synthesizeKey("VK_RIGHT", {}); + yield shown; + + let hidden = once(menus[0], "popuphidden"); + EventUtils.synthesizeKey("VK_LEFT", {}); + yield hidden; + + shown = once(menus[0], "popupshown"); + EventUtils.synthesizeKey("VK_RIGHT", {}); + yield shown; + + info("Clicking the submenu item"); + EventUtils.synthesizeMouseAtCenter(subMenuItems[0], {}, toolbox.win); + + yield 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 000000000..4092bf1a7 --- /dev/null +++ b/devtools/client/framework/test/browser_new_activation_workflow.js @@ -0,0 +1,69 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests devtools API + +var toolbox, target; + +var tempScope = {}; + +function test() { + addTab("about:blank").then(function (aTab) { + target = TargetFactory.forTab(gBrowser.selectedTab); + loadWebConsole(aTab).then(function () { + console.log("loaded"); + }); + }); +} + +function loadWebConsole(aTab) { + ok(gDevTools, "gDevTools exists"); + + return gDevTools.showToolbox(target, "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 () { + let tab = toolbox.doc.getElementById("toolbox-tab-" + id); + is(tab.hasAttribute("selected"), true, "The " + id + " tab is selected"); + }); +} + +function testToggle() { + toolbox.once("destroyed", () => { + // Cannot reuse a target after it's destroyed. + target = TargetFactory.forTab(gBrowser.selectedTab); + gDevTools.showToolbox(target, "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; + target = 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 000000000..af1808681 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-01.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Whitelisting this test. +// As part of bug 1077403, the leaking uncaught rejections should be fixed. +thisTestLeaksUncaughtRejectionsAndShouldBeFixed("[object Object]"); +thisTestLeaksUncaughtRejectionsAndShouldBeFixed( + "TypeError: this.transport is null"); + +/** + * 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. + */ + +// Force the old debugger UI since it's directly used (see Bug 1301705) +Services.prefs.setBoolPref("devtools.debugger.new-debugger-frontend", false); +registerCleanupFunction(function* () { + Services.prefs.clearUserPref("devtools.debugger.new-debugger-frontend"); +}); + +const DEBUGGER_ROOT = "http://example.com/browser/devtools/client/debugger/test/mochitest/"; +// Empty page +const PAGE_URL = `${DEBUGGER_ROOT}doc_empty-tab-01.html`; +const JS_URL = `${URL_ROOT}code_binary_search.js`; +const COFFEE_URL = `${URL_ROOT}code_binary_search.coffee`; +const { SourceMapService } = require("devtools/client/framework/source-map-service"); +const { serialize } = require("devtools/client/framework/location-store"); + +add_task(function* () { + const toolbox = yield openNewTabAndToolbox(PAGE_URL, "jsdebugger"); + const service = new SourceMapService(toolbox.target); + let aggregator = new Map(); + + function onUpdate(e, oldLoc, newLoc) { + if (oldLoc.line === 6) { + checkLoc1(oldLoc, newLoc); + } else if (oldLoc.line === 8) { + checkLoc2(oldLoc, newLoc); + } else { + throw new Error(`Unexpected location update: ${JSON.stringify(oldLoc)}`); + } + aggregator.set(serialize(oldLoc), newLoc); + } + + let loc1 = { url: JS_URL, line: 6 }; + let loc2 = { url: JS_URL, line: 8, column: 3 }; + + service.subscribe(loc1, onUpdate); + service.subscribe(loc2, onUpdate); + + // Inject JS script + let sourceShown = waitForSourceShown(toolbox.getCurrentPanel(), "code_binary_search"); + yield createScript(JS_URL); + yield sourceShown; + + yield waitUntil(() => aggregator.size === 2); + + aggregator = Array.from(aggregator.values()); + + ok(aggregator.find(i => i.url === COFFEE_URL && i.line === 4), "found first updated location"); + ok(aggregator.find(i => i.url === COFFEE_URL && i.line === 6), "found second updated location"); + + yield toolbox.destroy(); + gBrowser.removeCurrentTab(); + finish(); +}); + +function checkLoc1(oldLoc, newLoc) { + is(oldLoc.line, 6, "Correct line for JS:6"); + is(oldLoc.column, null, "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"); +} + +function createScript(url) { + info(`Creating script: ${url}`); + let mm = getFrameScript(); + let command = ` + let script = document.createElement("script"); + script.setAttribute("src", "${url}"); + document.body.appendChild(script); + null; + `; + return evalInDebuggee(mm, command); +} + +function waitForSourceShown(debuggerPanel, url) { + let { panelWin } = debuggerPanel; + let deferred = defer(); + + info(`Waiting for source ${url} to be shown in the debugger...`); + panelWin.on(panelWin.EVENTS.SOURCE_SHOWN, function onSourceShown(_, source) { + + let sourceUrl = source.url || source.generatedUrl; + if (sourceUrl.includes(url)) { + panelWin.off(panelWin.EVENTS.SOURCE_SHOWN, onSourceShown); + info(`Source shown for ${url}`); + deferred.resolve(source); + } + }); + + return deferred.promise; +} diff --git a/devtools/client/framework/test/browser_source_map-02.js b/devtools/client/framework/test/browser_source_map-02.js new file mode 100644 index 000000000..f31ce0175 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-02.js @@ -0,0 +1,113 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the SourceMapService updates generated sources when pretty printing + * and un pretty printing. + */ + +// Force the old debugger UI since it's directly used (see Bug 1301705) +Services.prefs.setBoolPref("devtools.debugger.new-debugger-frontend", false); +registerCleanupFunction(function* () { + Services.prefs.clearUserPref("devtools.debugger.new-debugger-frontend"); +}); + +const DEBUGGER_ROOT = "http://example.com/browser/devtools/client/debugger/test/mochitest/"; +// Empty page +const PAGE_URL = `${DEBUGGER_ROOT}doc_empty-tab-01.html`; +const JS_URL = `${URL_ROOT}code_ugly.js`; +const { SourceMapService } = require("devtools/client/framework/source-map-service"); + +add_task(function* () { + let toolbox = yield openNewTabAndToolbox(PAGE_URL, "jsdebugger"); + + let service = new SourceMapService(toolbox.target); + + let checkedPretty = false; + let checkedUnpretty = false; + + function onUpdate(e, oldLoc, newLoc) { + if (oldLoc.line === 3) { + checkPrettified(oldLoc, newLoc); + checkedPretty = true; + } else if (oldLoc.line === 9) { + checkUnprettified(oldLoc, newLoc); + checkedUnpretty = true; + } else { + throw new Error(`Unexpected location update: ${JSON.stringify(oldLoc)}`); + } + } + const loc1 = { url: JS_URL, line: 3 }; + service.subscribe(loc1, onUpdate); + + // Inject JS script + let sourceShown = waitForSourceShown(toolbox.getCurrentPanel(), "code_ugly.js"); + yield createScript(JS_URL); + yield sourceShown; + + let ppButton = toolbox.getCurrentPanel().panelWin.document.getElementById("pretty-print"); + sourceShown = waitForSourceShown(toolbox.getCurrentPanel(), "code_ugly.js"); + ppButton.click(); + yield sourceShown; + yield waitUntil(() => checkedPretty); + + // TODO check unprettified change once bug 1177446 fixed + // info("Testing un-pretty printing."); + // sourceShown = waitForSourceShown(toolbox.getCurrentPanel(), "code_ugly.js"); + // ppButton.click(); + // yield sourceShown; + // yield waitUntil(() => checkedUnpretty); + + + yield toolbox.destroy(); + gBrowser.removeCurrentTab(); + finish(); +}); + +function checkPrettified(oldLoc, newLoc) { + is(oldLoc.line, 3, "Correct line for JS:3"); + is(oldLoc.column, null, "Correct column for JS:3"); + is(oldLoc.url, JS_URL, "Correct url for JS:3"); + is(newLoc.line, 9, "Correct line for JS:3 -> PRETTY"); + is(newLoc.column, 0, "Correct column for JS:3 -> PRETTY"); + is(newLoc.url, JS_URL, "Correct url for JS:3 -> PRETTY"); +} + +function checkUnprettified(oldLoc, newLoc) { + is(oldLoc.line, 9, "Correct line for JS:3 -> PRETTY"); + is(oldLoc.column, 0, "Correct column for JS:3 -> PRETTY"); + is(oldLoc.url, JS_URL, "Correct url for JS:3 -> PRETTY"); + is(newLoc.line, 3, "Correct line for JS:3 -> UNPRETTIED"); + is(newLoc.column, null, "Correct column for JS:3 -> UNPRETTIED"); + is(newLoc.url, JS_URL, "Correct url for JS:3 -> UNPRETTIED"); +} + +function createScript(url) { + info(`Creating script: ${url}`); + let mm = getFrameScript(); + let command = ` + let script = document.createElement("script"); + script.setAttribute("src", "${url}"); + document.body.appendChild(script); + `; + return evalInDebuggee(mm, command); +} + +function waitForSourceShown(debuggerPanel, url) { + let { panelWin } = debuggerPanel; + let deferred = defer(); + + info(`Waiting for source ${url} to be shown in the debugger...`); + panelWin.on(panelWin.EVENTS.SOURCE_SHOWN, function onSourceShown(_, source) { + let sourceUrl = source.url || source.introductionUrl; + + if (sourceUrl.includes(url)) { + panelWin.off(panelWin.EVENTS.SOURCE_SHOWN, onSourceShown); + info(`Source shown for ${url}`); + deferred.resolve(source); + } + }); + + return deferred.promise; +} diff --git a/devtools/client/framework/test/browser_target_events.js b/devtools/client/framework/test/browser_target_events.js new file mode 100644 index 000000000..d0054a484 --- /dev/null +++ b/devtools/client/framework/test/browser_target_events.js @@ -0,0 +1,56 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var target; + +function test() +{ + waitForExplicitFinish(); + + gBrowser.selectedTab = gBrowser.addTab(); + BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(onLoad); +} + +function onLoad() { + target = TargetFactory.forTab(gBrowser.selectedTab); + + is(target.tab, gBrowser.selectedTab, "Target linked to the right tab."); + + target.once("hidden", onHidden); + gBrowser.selectedTab = gBrowser.addTab(); +} + +function onHidden() { + ok(true, "Hidden event received"); + target.once("visible", onVisible); + gBrowser.removeCurrentTab(); +} + +function onVisible() { + ok(true, "Visible event received"); + target.once("will-navigate", onWillNavigate); + let mm = getFrameScript(); + mm.sendAsyncMessage("devtools:test:navigate", { location: "data:text/html,<meta charset='utf8'/>test navigation" }); +} + +function onWillNavigate(event, request) { + ok(true, "will-navigate event received"); + // Wait for navigation handling to complete before removing the tab, in order + // to avoid triggering assertions. + target.once("navigate", executeSoon.bind(null, onNavigate)); +} + +function onNavigate() { + ok(true, "navigate event received"); + target.once("close", onClose); + gBrowser.removeCurrentTab(); +} + +function onClose() { + ok(true, "close event received"); + + target = null; + finish(); +} diff --git a/devtools/client/framework/test/browser_target_from_url.js b/devtools/client/framework/test/browser_target_from_url.js new file mode 100644 index 000000000..0707ee7d7 --- /dev/null +++ b/devtools/client/framework/test/browser_target_from_url.js @@ -0,0 +1,133 @@ +/* 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 } = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const { targetFromURL } = require("devtools/client/framework/target-from-url"); + +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 assertIsTabTarget(target, url, chrome = false) { + is(target.url, url); + is(target.isLocalTab, false); + is(target.chrome, chrome); + is(target.isTabActor, true); + is(target.isRemote, true); +} + +add_task(function* () { + let tab = yield addTab(TEST_URI); + let browser = tab.linkedBrowser; + let target; + + info("Test invalid type"); + try { + yield targetFromURL(new URL("http://foo?type=x")); + ok(false, "Shouldn't pass"); + } catch (e) { + is(e.message, "targetFromURL, unsupported type='x' parameter"); + } + + info("Test tab"); + let windowId = browser.outerWindowID; + target = yield targetFromURL(new URL("http://foo?type=tab&id=" + windowId)); + assertIsTabTarget(target, TEST_URI); + + info("Test tab with chrome privileges"); + target = yield targetFromURL(new URL("http://foo?type=tab&id=" + windowId + "&chrome")); + assertIsTabTarget(target, TEST_URI, true); + + info("Test invalid tab id"); + try { + yield targetFromURL(new URL("http://foo?type=tab&id=10000")); + ok(false, "Shouldn't pass"); + } catch (e) { + is(e.message, "targetFromURL, tab with outerWindowID:'10000' doesn't exist"); + } + + info("Test parent process"); + target = yield targetFromURL(new URL("http://foo?type=process")); + let topWindow = Services.wm.getMostRecentWindow("navigator:browser"); + assertIsTabTarget(target, topWindow.location.href, true); + + yield testRemoteTCP(); + yield testRemoteWebSocket(); + + gBrowser.removeCurrentTab(); +}); + +function* setupDebuggerServer(websocket) { + info("Create a separate loader instance for the DebuggerServer."); + let loader = new DevToolsLoader(); + let { DebuggerServer } = loader.require("devtools/server/main"); + + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + DebuggerServer.allowChromeProcess = true; + + let listener = DebuggerServer.createListener(); + ok(listener, "Socket listener created"); + // Pass -1 to automatically choose an available port + listener.portOrPath = -1; + listener.webSocket = websocket; + yield listener.open(); + is(DebuggerServer.listeningSockets, 1, "1 listening socket"); + + return { DebuggerServer, listener }; +} + +function teardownDebuggerServer({ DebuggerServer, listener }) { + info("Close the listener socket"); + listener.close(); + is(DebuggerServer.listeningSockets, 0, "0 listening sockets"); + + info("Destroy the temporary debugger server"); + DebuggerServer.destroy(); +} + +function* testRemoteTCP() { + info("Test remote process via TCP Connection"); + + let server = yield setupDebuggerServer(false); + + let { port } = server.listener; + let target = yield targetFromURL(new URL("http://foo?type=process&host=127.0.0.1&port=" + port)); + let topWindow = Services.wm.getMostRecentWindow("navigator:browser"); + assertIsTabTarget(target, topWindow.location.href, true); + + let settings = target.client._transport.connectionSettings; + is(settings.host, "127.0.0.1"); + is(settings.port, port); + is(settings.webSocket, false); + + yield target.client.close(); + + teardownDebuggerServer(server); +} + +function* testRemoteWebSocket() { + info("Test remote process via WebSocket Connection"); + + let server = yield setupDebuggerServer(true); + + let { port } = server.listener; + let target = yield targetFromURL(new URL("http://foo?type=process&host=127.0.0.1&port=" + port + "&ws=true")); + let topWindow = Services.wm.getMostRecentWindow("navigator:browser"); + assertIsTabTarget(target, topWindow.location.href, true); + + let settings = target.client._transport.connectionSettings; + is(settings.host, "127.0.0.1"); + is(settings.port, port); + is(settings.webSocket, true); + yield target.client.close(); + + teardownDebuggerServer(server); +} 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 000000000..b828d14ff --- /dev/null +++ b/devtools/client/framework/test/browser_target_remote.js @@ -0,0 +1,25 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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(); + + getChromeActors((client, response) => { + let options = { + form: response, + client: client, + chrome: true + }; + + TargetFactory.forRemoteTab(options).then(target => { + target.on("close", () => { + ok(true, "Target was closed"); + finish(); + }); + client.close(); + }); + }); +} 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 000000000..0cdbd565a --- /dev/null +++ b/devtools/client/framework/test/browser_target_support.js @@ -0,0 +1,74 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test support methods on Target, such as `hasActor`, `getActorDescription`, +// `actorHasMethod` and `getTrait`. + +var { WebAudioFront } = + require("devtools/shared/fronts/webaudio"); + +function* testTarget(client, target) { + yield target.makeRemote(); + + is(target.hasActor("timeline"), true, "target.hasActor() true when actor exists."); + is(target.hasActor("webaudio"), true, "target.hasActor() true when actor exists."); + is(target.hasActor("notreal"), false, "target.hasActor() false when actor does not exist."); + // Create a front to ensure the actor is loaded + let front = new WebAudioFront(target.client, target.form); + + let desc = yield target.getActorDescription("webaudio"); + is(desc.typeName, "webaudio", + "target.getActorDescription() returns definition data for corresponding actor"); + is(desc.events["start-context"]["type"], "startContext", + "target.getActorDescription() returns event data for corresponding actor"); + + desc = yield target.getActorDescription("nope"); + is(desc, undefined, "target.getActorDescription() returns undefined for non-existing actor"); + desc = yield target.getActorDescription(); + is(desc, undefined, "target.getActorDescription() returns undefined for undefined actor"); + + let hasMethod = yield target.actorHasMethod("audionode", "getType"); + is(hasMethod, true, + "target.actorHasMethod() returns true for existing actor with method"); + hasMethod = yield target.actorHasMethod("audionode", "nope"); + is(hasMethod, false, + "target.actorHasMethod() returns false for existing actor with no method"); + hasMethod = yield target.actorHasMethod("nope", "nope"); + is(hasMethod, false, + "target.actorHasMethod() returns false for non-existing actor with no method"); + hasMethod = yield target.actorHasMethod(); + is(hasMethod, false, + "target.actorHasMethod() returns false for undefined params"); + + is(target.getTrait("customHighlighters"), true, + "target.getTrait() returns boolean when trait exists"); + 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(); + + getChromeActors((client, response) => { + let options = { + form: response, + client: client, + chrome: true + }; + + TargetFactory.forRemoteTab(options).then(Task.async(testTarget).bind(null, client)); + }); +} + +function close(target, client) { + target.on("close", () => { + ok(true, "Target was closed"); + finish(); + }); + client.close(); +} diff --git a/devtools/client/framework/test/browser_toolbox_custom_host.js b/devtools/client/framework/test/browser_toolbox_custom_host.js new file mode 100644 index 000000000..5d3aeed54 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_custom_host.js @@ -0,0 +1,57 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = "data:text/html,test custom host"; + +function test() { + let {Toolbox} = require("devtools/client/framework/toolbox"); + + let toolbox, iframe, target; + + window.addEventListener("message", onMessage); + + iframe = document.createElement("iframe"); + document.documentElement.appendChild(iframe); + + addTab(TEST_URL).then(function (tab) { + target = TargetFactory.forTab(tab); + let options = {customIframe: iframe}; + gDevTools.showToolbox(target, null, Toolbox.HostType.CUSTOM, options) + .then(testCustomHost, console.error) + .then(null, console.error); + }); + + function onMessage(event) { + if (typeof(event.data) !== "string") { + return; + } + info("onMessage: " + event.data); + let json = JSON.parse(event.data); + if (json.name == "toolbox-close") { + ok("Got the `toolbox-close` message"); + window.removeEventListener("message", onMessage); + cleanup(); + } + } + + function testCustomHost(t) { + toolbox = t; + is(toolbox.win.top, window, "Toolbox is included in browser.xul"); + is(toolbox.doc, iframe.contentDocument, "Toolbox is in the custom iframe"); + executeSoon(() => gBrowser.removeCurrentTab()); + } + + function cleanup() { + iframe.remove(); + + // Even if we received "toolbox-close", the toolbox may still be destroying + // toolbox.destroy() returns a singleton promise that ensures + // everything is cleaned up before proceeding. + toolbox.destroy().then(() => { + toolbox = iframe = target = null; + finish(); + }); + } +} 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 000000000..2583ca68e --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_dynamic_registration.js @@ -0,0 +1,105 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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(tab => { + let target = TargetFactory.forTab(tab); + gDevTools.showToolbox(target).then(testRegister); + }); +} + +function testRegister(aToolbox) +{ + toolbox = aToolbox; + gDevTools.once("tool-registered", toolRegistered); + + gDevTools.registerTool({ + id: "test-tool", + label: "Test Tool", + inMenu: true, + isTargetSupported: () => true, + build: function () {}, + key: "t" + }); +} + +function toolRegistered(event, toolId) +{ + is(toolId, "test-tool", "tool-registered event handler sent tool id"); + + ok(gDevTools.getToolDefinitionMap().has(toolId), "tool added to map"); + + // test that it appeared in the UI + let doc = toolbox.doc; + let tab = doc.getElementById("toolbox-tab-" + toolId); + ok(tab, "new tool's tab exists in toolbox UI"); + + let panel = doc.getElementById("toolbox-panel-" + toolId); + ok(panel, "new tool's panel exists in toolbox UI"); + + for (let win of getAllBrowserWindows()) { + let key = win.document.getElementById("key_" + toolId); + ok(key, "key for new tool added to every browser window"); + let menuitem = win.document.getElementById("menuitem_" + toolId); + ok(menuitem, "menu item of new tool added to every browser window"); + } + + // then unregister it + testUnregister(); +} + +function getAllBrowserWindows() { + let wins = []; + let enumerator = Services.wm.getEnumerator("navigator:browser"); + while (enumerator.hasMoreElements()) { + wins.push(enumerator.getNext()); + } + return wins; +} + +function testUnregister() +{ + gDevTools.once("tool-unregistered", toolUnregistered); + + gDevTools.unregisterTool("test-tool"); +} + +function toolUnregistered(event, toolDefinition) +{ + let toolId = toolDefinition.id; + is(toolId, "test-tool", "tool-unregistered event handler sent tool id"); + + ok(!gDevTools.getToolDefinitionMap().has(toolId), "tool removed from map"); + + // test that it disappeared from the UI + let doc = toolbox.doc; + let tab = doc.getElementById("toolbox-tab-" + toolId); + ok(!tab, "tool's tab was removed from the toolbox UI"); + + let panel = doc.getElementById("toolbox-panel-" + toolId); + ok(!panel, "tool's panel was removed from toolbox UI"); + + for (let win of getAllBrowserWindows()) { + let key = win.document.getElementById("key_" + toolId); + ok(!key, "key removed from every browser window"); + let menuitem = win.document.getElementById("menuitem_" + toolId); + ok(!menuitem, "menu item removed from every browser window"); + } + + cleanup(); +} + +function cleanup() +{ + toolbox.destroy(); + toolbox = null; + gBrowser.removeCurrentTab(); + finish(); +} 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 000000000..21dd236a1 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_getpanelwhenready.js @@ -0,0 +1,36 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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(function* () { + let tab = yield addTab(URL); + let target = TargetFactory.forTab(tab); + toolbox = yield gDevTools.showToolbox(target); + + let debuggerPanelPromise = toolbox.getPanelWhenReady("jsdebugger"); + yield toolbox.selectTool("jsdebugger"); + let debuggerPanel = yield debuggerPanelPromise; + + is(debuggerPanel, toolbox.getPanel("jsdebugger"), + "The debugger panel from getPanelWhenReady before loading is the actual panel"); + + let debuggerPanel2 = yield toolbox.getPanelWhenReady("jsdebugger"); + is(debuggerPanel2, toolbox.getPanel("jsdebugger"), + "The debugger panel from getPanelWhenReady after loading is the actual panel"); + + yield cleanup(); +}); + +function* cleanup() { + yield 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 000000000..d197fdc99 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_highlight.js @@ -0,0 +1,81 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var {Toolbox} = require("devtools/client/framework/toolbox"); + +var toolbox = null; + +function test() { + const URL = "data:text/plain;charset=UTF-8,Nothing to see here, move along"; + + const TOOL_ID_1 = "jsdebugger"; + const TOOL_ID_2 = "webconsole"; + + addTab(URL).then(() => { + let target = TargetFactory.forTab(gBrowser.selectedTab); + gDevTools.showToolbox(target, TOOL_ID_1, Toolbox.HostType.BOTTOM) + .then(aToolbox => { + toolbox = aToolbox; + // select tool 2 + toolbox.selectTool(TOOL_ID_2) + // and highlight the first one + .then(highlightTab.bind(null, TOOL_ID_1)) + // to see if it has the proper class. + .then(checkHighlighted.bind(null, TOOL_ID_1)) + // Now switch back to first tool + .then(() => toolbox.selectTool(TOOL_ID_1)) + // to check again. But there is no easy way to test if + // it is showing orange or not. + .then(checkNoHighlightWhenSelected.bind(null, TOOL_ID_1)) + // Switch to tool 2 again + .then(() => toolbox.selectTool(TOOL_ID_2)) + // and check again. + .then(checkHighlighted.bind(null, TOOL_ID_1)) + // Now unhighlight the tool + .then(unhighlightTab.bind(null, TOOL_ID_1)) + // to see the classes gone. + .then(checkNoHighlight.bind(null, TOOL_ID_1)) + // Now close the toolbox and exit. + .then(() => executeSoon(() => { + toolbox.destroy() + .then(() => { + toolbox = null; + gBrowser.removeCurrentTab(); + finish(); + }); + })); + }); + }); +} + +function highlightTab(toolId) { + info("Highlighting tool " + toolId + "'s tab."); + toolbox.highlightTool(toolId); +} + +function unhighlightTab(toolId) { + info("Unhighlighting tool " + toolId + "'s tab."); + toolbox.unhighlightTool(toolId); +} + +function checkHighlighted(toolId) { + let tab = toolbox.doc.getElementById("toolbox-tab-" + toolId); + ok(tab.hasAttribute("highlighted"), "The highlighted attribute is present"); + ok(!tab.hasAttribute("selected") || tab.getAttribute("selected") != "true", + "The tab is not selected"); +} + +function checkNoHighlightWhenSelected(toolId) { + let tab = toolbox.doc.getElementById("toolbox-tab-" + toolId); + ok(tab.hasAttribute("highlighted"), "The highlighted attribute is present"); + ok(tab.hasAttribute("selected") && tab.getAttribute("selected") == "true", + "and the tab is selected, so the orange glow will not be present."); +} + +function checkNoHighlight(toolId) { + let tab = toolbox.doc.getElementById("toolbox-tab-" + toolId); + ok(!tab.hasAttribute("highlighted"), + "The highlighted attribute is not present"); +} 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 000000000..e16563ba7 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_hosts.js @@ -0,0 +1,139 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var {Toolbox} = require("devtools/client/framework/toolbox"); +var {SIDE, BOTTOM, WINDOW} = Toolbox.HostType; +var toolbox, target; + +const URL = "data:text/html;charset=utf8,test for opening toolbox in different hosts"; + +add_task(function* runTest() { + info("Create a test tab and open the toolbox"); + let tab = yield addTab(URL); + target = TargetFactory.forTab(tab); + toolbox = yield gDevTools.showToolbox(target, "webconsole"); + + yield testBottomHost(); + yield testSidebarHost(); + yield testWindowHost(); + yield testToolSelect(); + yield testDestroy(); + yield testRememberHost(); + yield testPreviousHost(); + + yield toolbox.destroy(); + + toolbox = target = null; + gBrowser.removeCurrentTab(); +}); + +function* testBottomHost() { + checkHostType(toolbox, BOTTOM); + + // test UI presence + let nbox = gBrowser.getNotificationBox(); + let iframe = document.getAnonymousElementByAttribute(nbox, "class", "devtools-toolbox-bottom-iframe"); + ok(iframe, "toolbox bottom iframe exists"); + + checkToolboxLoaded(iframe); +} + +function* testSidebarHost() { + yield toolbox.switchHost(SIDE); + checkHostType(toolbox, SIDE); + + // test UI presence + let nbox = gBrowser.getNotificationBox(); + let bottom = document.getAnonymousElementByAttribute(nbox, "class", "devtools-toolbox-bottom-iframe"); + ok(!bottom, "toolbox bottom iframe doesn't exist"); + + let iframe = document.getAnonymousElementByAttribute(nbox, "class", "devtools-toolbox-side-iframe"); + ok(iframe, "toolbox side iframe exists"); + + checkToolboxLoaded(iframe); +} + +function* testWindowHost() { + yield toolbox.switchHost(WINDOW); + checkHostType(toolbox, WINDOW); + + let nbox = gBrowser.getNotificationBox(); + let sidebar = document.getAnonymousElementByAttribute(nbox, "class", "devtools-toolbox-side-iframe"); + ok(!sidebar, "toolbox sidebar iframe doesn't exist"); + + let win = Services.wm.getMostRecentWindow("devtools:toolbox"); + ok(win, "toolbox separate window exists"); + + let iframe = win.document.getElementById("toolbox-iframe"); + checkToolboxLoaded(iframe); +} + +function* testToolSelect() { + // make sure we can load a tool after switching hosts + yield toolbox.selectTool("inspector"); +} + +function* testDestroy() { + yield toolbox.destroy(); + target = TargetFactory.forTab(gBrowser.selectedTab); + toolbox = yield gDevTools.showToolbox(target); +} + +function* testRememberHost() { + // last host was the window - make sure it's the same when re-opening + is(toolbox.hostType, WINDOW, "host remembered"); + + let win = Services.wm.getMostRecentWindow("devtools:toolbox"); + ok(win, "toolbox separate window exists"); +} + +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 side"); + yield toolbox.switchHost(SIDE); + checkHostType(toolbox, SIDE, WINDOW); + + info("Switching to bottom"); + yield toolbox.switchHost(BOTTOM); + checkHostType(toolbox, BOTTOM, SIDE); + + info("Switching from bottom to side"); + yield toolbox.switchToPreviousHost(); + checkHostType(toolbox, SIDE, BOTTOM); + + info("Switching from side to bottom"); + yield toolbox.switchToPreviousHost(); + checkHostType(toolbox, BOTTOM, SIDE); + + info("Switching to window"); + yield toolbox.switchHost(WINDOW); + checkHostType(toolbox, WINDOW, BOTTOM); + + info("Switching from window to bottom"); + yield 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 side (since previous=current=bottom"); + yield toolbox.switchToPreviousHost(); + checkHostType(toolbox, SIDE, BOTTOM); + + info("Forcing the previous host to match the current (side)"); + Services.prefs.setCharPref("devtools.toolbox.previousHost", SIDE); + info("Switching from side to bottom (since previous=current=side"); + yield toolbox.switchToPreviousHost(); + checkHostType(toolbox, BOTTOM, SIDE); +} + +function checkToolboxLoaded(iframe) { + let tabs = iframe.contentDocument.getElementById("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 000000000..4286fe438 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_hosts_size.js @@ -0,0 +1,69 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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"; + +var {Toolbox} = require("devtools/client/framework/toolbox"); + +add_task(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); + + let tab = yield addTab(URL); + let nbox = gBrowser.getNotificationBox(); + let {clientHeight: nboxHeight, clientWidth: nboxWidth} = nbox; + let toolbox = yield gDevTools.showToolbox(TargetFactory.forTab(tab)); + + is(nbox.clientHeight, nboxHeight, "Opening the toolbox hasn't changed the height of the nbox"); + is(nbox.clientWidth, nboxWidth, "Opening the toolbox hasn't changed the width of the nbox"); + + let iframe = document.getAnonymousElementByAttribute(nbox, "class", "devtools-toolbox-bottom-iframe"); + is(iframe.clientHeight, nboxHeight - 25, "The iframe fits within the available space"); + + yield toolbox.switchHost(Toolbox.HostType.SIDE); + iframe = document.getAnonymousElementByAttribute(nbox, "class", "devtools-toolbox-side-iframe"); + iframe.style.minWidth = "1px"; // Disable the min width set in css + is(iframe.clientWidth, nboxWidth - 25, "The iframe fits within the available space"); + + yield cleanup(toolbox); +}); + +add_task(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); + + let tab = yield addTab(URL); + let nbox = gBrowser.getNotificationBox(); + let {clientHeight: nboxHeight, clientWidth: nboxWidth} = nbox; + let toolbox = yield gDevTools.showToolbox(TargetFactory.forTab(tab)); + + is(nbox.clientHeight, nboxHeight, "Opening the toolbox hasn't changed the height of the nbox"); + is(nbox.clientWidth, nboxWidth, "Opening the toolbox hasn't changed the width of the nbox"); + + let iframe = document.getAnonymousElementByAttribute(nbox, "class", "devtools-toolbox-bottom-iframe"); + is(iframe.clientHeight, 100, "The iframe is resized properly"); + + yield toolbox.switchHost(Toolbox.HostType.SIDE); + iframe = document.getAnonymousElementByAttribute(nbox, "class", "devtools-toolbox-side-iframe"); + iframe.style.minWidth = "1px"; // Disable the min width set in css + is(iframe.clientWidth, 100, "The iframe is resized properly"); + + yield cleanup(toolbox); +}); + +function* cleanup(toolbox) { + Services.prefs.clearUserPref("devtools.toolbox.host"); + Services.prefs.clearUserPref("devtools.toolbox.footer.height"); + Services.prefs.clearUserPref("devtools.toolbox.sidebar.width"); + yield 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 000000000..f8ff9b3e4 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_hosts_telemetry.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const {Toolbox} = require("devtools/client/framework/toolbox"); +const {SIDE, BOTTOM, WINDOW} = Toolbox.HostType; + +const URL = "data:text/html;charset=utf8,browser_toolbox_hosts_telemetry.js"; + +function getHostHistogram() { + return Services.telemetry.getHistogramById("DEVTOOLS_TOOLBOX_HOST"); +} + +add_task(function* () { + // Reset it to make counting easier + getHostHistogram().clear(); + + info("Create a test tab and open the toolbox"); + let tab = yield addTab(URL); + let target = TargetFactory.forTab(tab); + let toolbox = yield gDevTools.showToolbox(target, "webconsole"); + + yield changeToolboxHost(toolbox); + yield checkResults(); + yield toolbox.destroy(); + + toolbox = target = null; + gBrowser.removeCurrentTab(); + + // Cleanup + getHostHistogram().clear(); +}); + +function* changeToolboxHost(toolbox) { + info("Switch toolbox host"); + yield toolbox.switchHost(SIDE); + yield toolbox.switchHost(WINDOW); + yield toolbox.switchHost(BOTTOM); + yield toolbox.switchHost(SIDE); + yield toolbox.switchHost(WINDOW); + yield toolbox.switchHost(BOTTOM); +} + +function checkResults() { + let counts = getHostHistogram().snapshot().counts; + is(counts[0], 3, "Toolbox HostType bottom has 3 successful entries"); + is(counts[1], 2, "Toolbox HostType side has 2 successful entries"); + is(counts[2], 2, "Toolbox HostType window has 2 successful entries"); +} 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 000000000..a22f87064 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_keyboard_navigation.js @@ -0,0 +1,81 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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(function* () { + info("Create a test tab and open the toolbox"); + let toolbox = yield openNewTabAndToolbox(TEST_URL, "webconsole"); + let doc = toolbox.doc; + + let toolbar = doc.querySelector(".devtools-tabbar"); + let 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("VK_TAB", {}); + ok(!containsFocus(doc, toolbar), "Focus is outside of the toolbar"); + + // Move the focus back to the toolbar. + EventUtils.synthesizeKey("VK_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("VK_RIGHT", {}); + } + } + + // Move the focus away from toolbar to a next focusable element. + EventUtils.synthesizeKey("VK_TAB", {}); + ok(!containsFocus(doc, toolbar), "Focus is outside of the toolbar"); + + // Move the focus back to the toolbar. + EventUtils.synthesizeKey("VK_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("VK_LEFT", {}); } + } + + // Move focus to the 3rd (non-first) toolbar control. + let expectedFocusedControl = toolbarControls[2]; + EventUtils.synthesizeKey("VK_RIGHT", {}); + EventUtils.synthesizeKey("VK_RIGHT", {}); + is(doc.activeElement.id, expectedFocusedControl.id, "New control is focused"); + + // Move the focus away from toolbar to a next focusable element. + EventUtils.synthesizeKey("VK_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("VK_TAB", { shiftKey: true }); + is(doc.activeElement.id, expectedFocusedControl.id, "New control is focused"); +}); diff --git a/devtools/client/framework/test/browser_toolbox_minimize.js b/devtools/client/framework/test/browser_toolbox_minimize.js new file mode 100644 index 000000000..9b5126320 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_minimize.js @@ -0,0 +1,106 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that when the toolbox is displayed in a bottom host, that host can be +// minimized to just the tabbar height, and maximized again. +// Also test that while minimized, switching to a tool, clicking on the +// settings, or clicking on the selected tool's tab maximizes the toolbox again. +// Finally test that the minimize button doesn't exist in other host types. + +const URL = "data:text/html;charset=utf8,test page"; +const {Toolbox} = require("devtools/client/framework/toolbox"); + +add_task(function* () { + info("Create a test tab and open the toolbox"); + let tab = yield addTab(URL); + let target = TargetFactory.forTab(tab); + let toolbox = yield gDevTools.showToolbox(target, "webconsole"); + + let button = toolbox.doc.querySelector("#toolbox-dock-bottom-minimize"); + ok(button, "The minimize button exists in the default bottom host"); + + info("Try to minimize the toolbox"); + yield minimize(toolbox); + ok(parseInt(toolbox._host.frame.style.marginBottom, 10) < 0, + "The toolbox host has been hidden away with a negative-margin"); + + info("Try to maximize again the toolbox"); + yield maximize(toolbox); + ok(parseInt(toolbox._host.frame.style.marginBottom, 10) == 0, + "The toolbox host is shown again"); + + info("Try to minimize again using the keyboard shortcut"); + yield minimizeWithShortcut(toolbox); + ok(parseInt(toolbox._host.frame.style.marginBottom, 10) < 0, + "The toolbox host has been hidden away with a negative-margin"); + + info("Try to maximize again using the keyboard shortcut"); + yield maximizeWithShortcut(toolbox); + ok(parseInt(toolbox._host.frame.style.marginBottom, 10) == 0, + "The toolbox host is shown again"); + + info("Minimize again and switch to another tool"); + yield minimize(toolbox); + let onMaximized = toolbox._host.once("maximized"); + yield toolbox.selectTool("inspector"); + yield onMaximized; + + info("Minimize again and click on the tab of the current tool"); + yield minimize(toolbox); + onMaximized = toolbox._host.once("maximized"); + let tabButton = toolbox.doc.querySelector("#toolbox-tab-inspector"); + EventUtils.synthesizeMouseAtCenter(tabButton, {}, toolbox.win); + yield onMaximized; + + info("Minimize again and click on the settings tab"); + yield minimize(toolbox); + onMaximized = toolbox._host.once("maximized"); + let settingsButton = toolbox.doc.querySelector("#toolbox-tab-options"); + EventUtils.synthesizeMouseAtCenter(settingsButton, {}, toolbox.win); + yield onMaximized; + + info("Switch to a different host"); + yield toolbox.switchHost(Toolbox.HostType.SIDE); + button = toolbox.doc.querySelector("#toolbox-dock-bottom-minimize"); + ok(!button, "The minimize button doesn't exist in the side host"); + + Services.prefs.clearUserPref("devtools.toolbox.host"); + yield toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +function* minimize(toolbox) { + let button = toolbox.doc.querySelector("#toolbox-dock-bottom-minimize"); + let onMinimized = toolbox._host.once("minimized"); + EventUtils.synthesizeMouseAtCenter(button, {}, toolbox.win); + yield onMinimized; +} + +function* minimizeWithShortcut(toolbox) { + let key = toolbox.doc.getElementById("toolbox-minimize-key") + .getAttribute("key"); + let onMinimized = toolbox._host.once("minimized"); + EventUtils.synthesizeKey(key, {accelKey: true, shiftKey: true}, + toolbox.win); + yield onMinimized; +} + +function* maximize(toolbox) { + let button = toolbox.doc.querySelector("#toolbox-dock-bottom-minimize"); + let onMaximized = toolbox._host.once("maximized"); + EventUtils.synthesizeMouseAtCenter(button, {}, toolbox.win); + yield onMaximized; +} + +function* maximizeWithShortcut(toolbox) { + let key = toolbox.doc.getElementById("toolbox-minimize-key") + .getAttribute("key"); + let onMaximized = toolbox._host.once("maximized"); + EventUtils.synthesizeKey(key, {accelKey: true, shiftKey: true}, + toolbox.win); + yield onMaximized; +} 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 000000000..569ed86fb --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options.js @@ -0,0 +1,297 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from shared-head.js */ +"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 {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties"); + +add_task(function* () { + const URL = "data:text/html;charset=utf8,test for dynamically registering " + + "and unregistering tools"; + registerNewTool(); + let tab = yield addTab(URL); + let target = TargetFactory.forTab(tab); + toolbox = yield gDevTools.showToolbox(target); + doc = toolbox.doc; + yield testSelectTool(); + yield testOptionsShortcut(); + yield testOptions(); + yield testToggleTools(); + yield cleanup(); +}); + +function registerNewTool() { + let toolDefinition = { + id: "test-tool", + isTargetSupported: () => true, + visibilityswitch: "devtools.test-tool.enabled", + url: "about:blank", + label: "someLabel" + }; + + ok(gDevTools, "gDevTools exists"); + ok(!gDevTools.getToolDefinitionMap().has("test-tool"), + "The tool is not registered"); + + gDevTools.registerTool(toolDefinition); + ok(gDevTools.getToolDefinitionMap().has("test-tool"), + "The tool is registered"); +} + +function* testSelectTool() { + info("Checking to make sure that the options panel can be selected."); + + let onceSelected = toolbox.once("options-selected"); + toolbox.selectTool("options"); + yield onceSelected; + ok(true, "Toolbox selected via selectTool method"); +} + +function* testOptionsShortcut() { + info("Selecting another tool, then reselecting options panel with keyboard."); + + yield toolbox.selectTool("webconsole"); + is(toolbox.currentToolId, "webconsole", "webconsole is selected"); + synthesizeKeyShortcut(L10N.getStr("toolbox.options.key")); + is(toolbox.currentToolId, "options", "Toolbox selected via shortcut key (1)"); + synthesizeKeyShortcut(L10N.getStr("toolbox.options.key")); + is(toolbox.currentToolId, "webconsole", "webconsole is selected (1)"); + + yield 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 (2)"); + synthesizeKeyShortcut(L10N.getStr("toolbox.options.key")); + is(toolbox.currentToolId, "webconsole", "webconsole is reselected (2)"); + synthesizeKeyShortcut(L10N.getStr("toolbox.help.key")); + is(toolbox.currentToolId, "options", "Toolbox selected via shortcut key (2)"); +} + +function* testOptions() { + let tool = toolbox.getPanel("options"); + panelWin = tool.panelWin; + let prefNodes = tool.panelDoc.querySelectorAll( + "input[type=checkbox][data-pref]"); + + // Store modified pref names so that they can be cleared on error. + for (let node of tool.panelDoc.querySelectorAll("[data-pref]")) { + let pref = node.getAttribute("data-pref"); + modifiedPrefs.push(pref); + } + + for (let node of prefNodes) { + let prefValue = GetPref(node.getAttribute("data-pref")); + + // Test clicking the checkbox for each options pref + yield testMouseClick(node, prefValue); + + // Do again with opposite values to reset prefs + yield testMouseClick(node, !prefValue); + } + + let prefSelects = tool.panelDoc.querySelectorAll("select[data-pref]"); + for (let node of prefSelects) { + yield testSelect(node); + } +} + +function* testSelect(select) { + let pref = select.getAttribute("data-pref"); + let options = Array.from(select.options); + info("Checking select for: " + pref); + + is(select.options[select.selectedIndex].value, GetPref(pref), + "select starts out selected"); + + for (let option of options) { + if (options.indexOf(option) === select.selectedIndex) { + continue; + } + + let deferred = defer(); + gDevTools.once("pref-changed", (event, data) => { + if (data.pref == pref) { + ok(true, "Correct pref was changed"); + is(GetPref(pref), option.value, "Preference been switched for " + pref); + } else { + ok(false, "Pref " + pref + " was not changed correctly"); + } + deferred.resolve(); + }); + + select.selectedIndex = options.indexOf(option); + let changeEvent = new Event("change"); + select.dispatchEvent(changeEvent); + + yield deferred.promise; + } +} + +function* testMouseClick(node, prefValue) { + let deferred = defer(); + + let pref = node.getAttribute("data-pref"); + gDevTools.once("pref-changed", (event, data) => { + if (data.pref == pref) { + ok(true, "Correct pref was changed"); + is(data.oldValue, prefValue, "Previous value is correct for " + pref); + is(data.newValue, !prefValue, "New value is correct for " + pref); + } else { + ok(false, "Pref " + pref + " was not changed correctly"); + } + deferred.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); + }); + + yield deferred.promise; +} + +function* testToggleTools() { + let toolNodes = panelWin.document.querySelectorAll( + "#default-tools-box input[type=checkbox]:not([data-unsupported])," + + "#additional-tools-box input[type=checkbox]:not([data-unsupported])"); + let enabledTools = [...toolNodes].filter(node => node.checked); + + let toggleableTools = gDevTools.getDefaultTools().filter(tool => { + return tool.visibilityswitch; + }).concat(gDevTools.getAdditionalTools()); + + for (let node of toolNodes) { + let 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 (let tool of toggleableTools) { + let pref = tool.visibilityswitch; + modifiedPrefs.push(pref); + } + + // Toggle each tool + for (let node of toolNodes) { + yield toggleTool(node); + } + // Toggle again to reset tool enablement state + for (let node of toolNodes) { + yield toggleTool(node); + } + + // Test that a tool can still be added when no tabs are present: + // Disable all tools + for (let node of enabledTools) { + yield toggleTool(node); + } + // Re-enable the tools which are enabled by default + for (let node of enabledTools) { + yield toggleTool(node); + } + + // Toggle first, middle, and last tools to ensure that toolbox tabs are + // inserted in order + let firstTool = toolNodes[0]; + let middleTool = toolNodes[(toolNodes.length / 2) | 0]; + let lastTool = toolNodes[toolNodes.length - 1]; + + yield toggleTool(firstTool); + yield toggleTool(firstTool); + yield toggleTool(middleTool); + yield toggleTool(middleTool); + yield toggleTool(lastTool); + yield toggleTool(lastTool); +} + +function* toggleTool(node) { + let deferred = defer(); + + let toolId = node.getAttribute("id"); + if (node.checked) { + gDevTools.once("tool-unregistered", + checkUnregistered.bind(null, toolId, deferred)); + } else { + gDevTools.once("tool-registered", + checkRegistered.bind(null, toolId, deferred)); + } + node.scrollIntoView(); + EventUtils.synthesizeMouseAtCenter(node, {}, panelWin); + + yield deferred.promise; +} + +function checkUnregistered(toolId, deferred, event, data) { + if (data.id == 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"); + } + deferred.resolve(); +} + +function checkRegistered(toolId, deferred, event, data) { + if (data == toolId) { + ok(true, "Correct tool added back"); + // checking tab on the toolbox + let radio = doc.getElementById("toolbox-tab-" + toolId); + ok(radio, "Tab added back for " + toolId); + if (radio.previousSibling) { + ok(+radio.getAttribute("ordinal") >= + +radio.previousSibling.getAttribute("ordinal"), + "Inserted tab's ordinal is greater than equal to its previous tab." + + "Expected " + radio.getAttribute("ordinal") + " >= " + + radio.previousSibling.getAttribute("ordinal")); + } + if (radio.nextSibling) { + ok(+radio.getAttribute("ordinal") < + +radio.nextSibling.getAttribute("ordinal"), + "Inserted tab's ordinal is less than its next tab. Expected " + + radio.getAttribute("ordinal") + " < " + + radio.nextSibling.getAttribute("ordinal")); + } + } else { + ok(false, "Something went wrong, " + toolId + " was not registered"); + } + deferred.resolve(); +} + +function GetPref(name) { + let 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* cleanup() { + gDevTools.unregisterTool("test-tool"); + yield toolbox.destroy(); + gBrowser.removeCurrentTab(); + for (let 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 000000000..09cde4393 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_buttons.js @@ -0,0 +1,163 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from shared-head.js */ +"use strict"; + +const TEST_URL = "data:text/html;charset=utf8,test for dynamically " + + "registering and unregistering tools"; +var doc = null, toolbox = null, panelWin = null, modifiedPrefs = []; + +function test() { + addTab(TEST_URL).then(tab => { + let target = TargetFactory.forTab(tab); + gDevTools.showToolbox(target) + .then(testSelectTool) + .then(testToggleToolboxButtons) + .then(testPrefsAreRespectedWhenReopeningToolbox) + .then(cleanup, errorHandler); + }); +} + +function testPrefsAreRespectedWhenReopeningToolbox() { + let deferred = defer(); + let target = TargetFactory.forTab(gBrowser.selectedTab); + + info("Closing toolbox to test after reopening"); + gDevTools.closeToolbox(target).then(() => { + let tabTarget = TargetFactory.forTab(gBrowser.selectedTab); + gDevTools.showToolbox(tabTarget) + .then(testSelectTool) + .then(() => { + info("Toolbox has been reopened. Checking UI state."); + testPreferenceAndUIStateIsConsistent(); + deferred.resolve(); + }); + }); + + return deferred.promise; +} + +function testSelectTool(devtoolsToolbox) { + let deferred = defer(); + info("Selecting the options panel"); + + toolbox = devtoolsToolbox; + doc = toolbox.doc; + toolbox.once("options-selected", (event, tool) => { + ok(true, "Options panel selected via selectTool method"); + panelWin = tool.panelWin; + deferred.resolve(); + }); + toolbox.selectTool("options"); + + return deferred.promise; +} + +function testPreferenceAndUIStateIsConsistent() { + let checkNodes = [...panelWin.document.querySelectorAll( + "#enabled-toolbox-buttons-box input[type=checkbox]")]; + let toolboxButtonNodes = [...doc.querySelectorAll(".command-button")]; + toolboxButtonNodes.push(doc.getElementById("command-button-frames")); + let toggleableTools = toolbox.toolboxButtons; + + // The noautohide button is only displayed in the browser toolbox + toggleableTools = toggleableTools.filter( + tool => tool.id != "command-button-noautohide"); + + for (let tool of toggleableTools) { + let isVisible = getBoolPref(tool.visibilityswitch); + + let button = toolboxButtonNodes.filter( + toolboxButton => toolboxButton.id === tool.id)[0]; + is(!button.hasAttribute("hidden"), isVisible, + "Button visibility matches pref for " + tool.id); + + let check = checkNodes.filter(node => node.id === tool.id)[0]; + is(check.checked, isVisible, + "Checkbox should be selected based on current pref for " + tool.id); + } +} + +function testToggleToolboxButtons() { + let checkNodes = [...panelWin.document.querySelectorAll( + "#enabled-toolbox-buttons-box input[type=checkbox]")]; + let toolboxButtonNodes = [...doc.querySelectorAll(".command-button")]; + let toggleableTools = toolbox.toolboxButtons; + + // The noautohide button is only displayed in the browser toolbox, and the element + // picker button is not toggleable. + toggleableTools = toggleableTools.filter( + tool => tool.id != "command-button-noautohide" && tool.id != "command-button-pick"); + toolboxButtonNodes = toolboxButtonNodes.filter( + btn => btn.id != "command-button-noautohide" && btn.id != "command-button-pick"); + + is(checkNodes.length, toggleableTools.length, + "All of the buttons are toggleable."); + is(checkNodes.length, toolboxButtonNodes.length, + "All of the DOM buttons are toggleable."); + + for (let tool of toggleableTools) { + let id = tool.id; + let matchedCheckboxes = checkNodes.filter(node => node.id === id); + let matchedButtons = toolboxButtonNodes.filter(button => button.id === id); + is(matchedCheckboxes.length, 1, + "There should be a single toggle checkbox for: " + id); + is(matchedButtons.length, 1, + "There should be a DOM button for: " + id); + is(matchedButtons[0], tool.button, + "DOM buttons should match for: " + id); + + is(matchedCheckboxes[0].nextSibling.textContent, tool.label, + "The label for checkbox matches the tool definition."); + is(matchedButtons[0].getAttribute("title"), tool.label, + "The tooltip for button matches the tool definition."); + } + + // Store modified pref names so that they can be cleared on error. + for (let tool of toggleableTools) { + let pref = tool.visibilityswitch; + modifiedPrefs.push(pref); + } + + // Try checking each checkbox, making sure that it changes the preference + for (let node of checkNodes) { + let tool = toggleableTools.filter( + toggleableTool => toggleableTool.id === node.id)[0]; + let isVisible = getBoolPref(tool.visibilityswitch); + + testPreferenceAndUIStateIsConsistent(); + node.click(); + testPreferenceAndUIStateIsConsistent(); + + let isVisibleAfterClick = getBoolPref(tool.visibilityswitch); + + is(isVisible, !isVisibleAfterClick, + "Clicking on the node should have toggled visibility preference for " + + tool.visibilityswitch); + } + + return promise.resolve(); +} + +function getBoolPref(key) { + return Services.prefs.getBoolPref(key); +} + +function cleanup() { + toolbox.destroy().then(function () { + gBrowser.removeCurrentTab(); + for (let pref of modifiedPrefs) { + Services.prefs.clearUserPref(pref); + } + toolbox = doc = panelWin = modifiedPrefs = null; + finish(); + }); +} + +function errorHandler(error) { + ok(false, "Unexpected error: " + error); + cleanup(); +} 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 000000000..6badf069e --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_cache-01.js @@ -0,0 +1,34 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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. +loadHelperScript("helper_disable_cache.js"); + +add_task(function* () { + // 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 (let tab of tabs) { + yield initTab(tab, tab.startToolbox); + } + + // Ensure cache is enabled for all tabs. + yield checkCacheStateForAllTabs([true, true, true, true]); + + // Check the checkbox in tab 0 and ensure cache is disabled for tabs 0 and 1. + yield setDisableCacheCheckboxChecked(tabs[0], true); + yield checkCacheStateForAllTabs([false, false, true, true]); + + yield 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 000000000..38c381cef --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_cache-02.js @@ -0,0 +1,47 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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. +loadHelperScript("helper_disable_cache.js"); + +add_task(function* () { + // 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 (let tab of tabs) { + yield initTab(tab, tab.startToolbox); + } + + // Disable cache in tab 0 + yield setDisableCacheCheckboxChecked(tabs[0], true); + + // Open toolbox in tab 2 and ensure the cache is then disabled. + tabs[2].toolbox = yield gDevTools.showToolbox(tabs[2].target, "options"); + yield checkCacheEnabled(tabs[2], false); + + // Close toolbox in tab 2 and ensure the cache is enabled again + yield tabs[2].toolbox.destroy(); + tabs[2].target = TargetFactory.forTab(tabs[2].tab); + yield checkCacheEnabled(tabs[2], true); + + // Open toolbox in tab 2 and ensure the cache is then disabled. + tabs[2].toolbox = yield gDevTools.showToolbox(tabs[2].target, "options"); + yield checkCacheEnabled(tabs[2], false); + + // Check the checkbox in tab 2 and ensure cache is enabled for all tabs. + yield setDisableCacheCheckboxChecked(tabs[2], false); + yield checkCacheStateForAllTabs([true, true, true, true]); + + yield finishUp(); +}); 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 000000000..c6c336981 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_cache.sjs @@ -0,0 +1,28 @@ + /* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(request, response) { + let Etag = '"4d881ab-b03-435f0a0f9ef00"'; + let IfNoneMatch = request.hasHeader("If-None-Match") + ? request.getHeader("If-None-Match") + : ""; + + let guid = 'xxxxxxxx-xxxx-xxxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + let r = Math.random() * 16 | 0; + let v = c === "x" ? r : (r & 0x3 | 0x8); + + return v.toString(16); + }); + + let 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 000000000..8df1119f6 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_js.html @@ -0,0 +1,46 @@ +<!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;version=1.8"> + function log(msg) { + let 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 000000000..b0c14a805 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_js.js @@ -0,0 +1,119 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 + "browser_toolbox_options_disable_js.html"; + +function test() { + addTab(TEST_URI).then(tab => { + let target = TargetFactory.forTab(tab); + gDevTools.showToolbox(target).then(testSelectTool); + }); +} + +function testSelectTool(toolbox) { + toolbox.once("options-selected", () => testToggleJS(toolbox)); + toolbox.selectTool("options"); +} + +let testToggleJS = Task.async(function* (toolbox) { + ok(true, "Toolbox selected via selectTool method"); + + yield testJSEnabled(); + yield testJSEnabledIframe(); + + // Disable JS. + yield toggleJS(toolbox); + + yield testJSDisabled(); + yield testJSDisabledIframe(); + + // Re-enable JS. + yield toggleJS(toolbox); + + yield testJSEnabled(); + yield testJSEnabledIframe(); + + finishUp(toolbox); +}); + +function* testJSEnabled() { + info("Testing that JS is enabled"); + + // We use waitForTick here because switching docShell.allowJavascript to true + // takes a while to become live. + yield waitForTick(); + + yield ContentTask.spawn(gBrowser.selectedBrowser, {}, function () { + let doc = content.document; + let output = doc.getElementById("output"); + doc.querySelector("#logJSEnabled").click(); + is(output.textContent, "JavaScript Enabled", 'Output is "JavaScript Enabled"'); + }); +} + +function* testJSEnabledIframe() { + info("Testing that JS is enabled in the iframe"); + + yield ContentTask.spawn(gBrowser.selectedBrowser, {}, function () { + let doc = content.document; + let iframe = doc.querySelector("iframe"); + let iframeDoc = iframe.contentDocument; + let output = iframeDoc.getElementById("output"); + iframeDoc.querySelector("#logJSEnabled").click(); + is(output.textContent, "JavaScript Enabled", + 'Output is "JavaScript Enabled" in iframe'); + }); +} + +function* toggleJS(toolbox) { + let panel = toolbox.getCurrentPanel(); + let 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 browserLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + cbx.click(); + yield browserLoaded; +} + +function* testJSDisabled() { + info("Testing that JS is disabled"); + + yield ContentTask.spawn(gBrowser.selectedBrowser, {}, function () { + let doc = content.document; + let output = doc.getElementById("output"); + doc.querySelector("#logJSDisabled").click(); + + ok(output.textContent !== "JavaScript Disabled", + 'output is not "JavaScript Disabled"'); + }); +} + +function* testJSDisabledIframe() { + info("Testing that JS is disabled in the iframe"); + + yield ContentTask.spawn(gBrowser.selectedBrowser, {}, function () { + let doc = content.document; + let iframe = doc.querySelector("iframe"); + let iframeDoc = iframe.contentDocument; + let output = iframeDoc.getElementById("output"); + iframeDoc.querySelector("#logJSDisabled").click(); + ok(output.textContent !== "JavaScript Disabled", + 'output is not "JavaScript Disabled" in iframe'); + }); +} + +function finishUp(toolbox) { + toolbox.destroy().then(function () { + gBrowser.removeCurrentTab(); + finish(); + }); +} 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 000000000..777bf86bf --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_js_iframe.html @@ -0,0 +1,33 @@ +<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;version=1.8"> + function log(msg) { + let 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 000000000..0e4b824cb --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> + <head> + <title>browser_toolbox_options_enable_serviceworkers_testing.html</title> + <meta charset="UTF-8"> + </head> + <body> + <h1>SW-test</h1> + </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 000000000..3273f4395 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.js @@ -0,0 +1,126 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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. + +const COMMON_FRAME_SCRIPT_URL = + "chrome://devtools/content/shared/frame-script-utils.js"; +const ROOT_TEST_DIR = + getRootDirectory(gTestPath); +const FRAME_SCRIPT_URL = + ROOT_TEST_DIR + + "browser_toolbox_options_enable_serviceworkers_testing_frame_script.js"; +const TEST_URI = URL_ROOT + + "browser_toolbox_options_enable_serviceworkers_testing.html"; + +const ELEMENT_ID = "devtools-enable-serviceWorkersTesting"; + +var toolbox; + +function test() { + // Note: Pref dom.serviceWorkers.testing.enabled is false since we are testing + // the same capabilities are enabled with the devtool pref. + SpecialPowers.pushPrefEnv({"set": [ + ["dom.serviceWorkers.exemptFromPerDomainMax", true], + ["dom.serviceWorkers.enabled", true], + ["dom.serviceWorkers.testing.enabled", false] + ]}, init); +} + +function init() { + addTab(TEST_URI).then(tab => { + let target = TargetFactory.forTab(tab); + let linkedBrowser = tab.linkedBrowser; + + linkedBrowser.messageManager.loadFrameScript(COMMON_FRAME_SCRIPT_URL, false); + linkedBrowser.messageManager.loadFrameScript(FRAME_SCRIPT_URL, false); + + gDevTools.showToolbox(target).then(testSelectTool); + }); +} + +function testSelectTool(aToolbox) { + toolbox = aToolbox; + toolbox.once("options-selected", start); + toolbox.selectTool("options"); +} + +function register() { + return executeInContent("devtools:sw-test:register"); +} + +function unregister(swr) { + return executeInContent("devtools:sw-test:unregister"); +} + +function registerAndUnregisterInFrame() { + return executeInContent("devtools:sw-test:iframe:register-and-unregister"); +} + +function testRegisterFails(data) { + is(data.success, false, "Register should fail with security error"); + return promise.resolve(); +} + +function toggleServiceWorkersTestingCheckbox() { + let panel = toolbox.getCurrentPanel(); + let cbx = panel.panelDoc.getElementById(ELEMENT_ID); + + cbx.scrollIntoView(); + + if (cbx.checked) { + info("Clearing checkbox to disable service workers testing"); + } else { + info("Checking checkbox to enable service workers testing"); + } + + cbx.click(); + + return promise.resolve(); +} + +function reload() { + let deferred = defer(); + + gBrowser.selectedBrowser.addEventListener("load", function onLoad(evt) { + gBrowser.selectedBrowser.removeEventListener(evt.type, onLoad, true); + deferred.resolve(); + }, true); + + executeInContent("devtools:test:reload", {}, {}, false); + return deferred.promise; +} + +function testRegisterSuccesses(data) { + is(data.success, true, "Register should success"); + return promise.resolve(); +} + +function start() { + register() + .then(testRegisterFails) + .then(toggleServiceWorkersTestingCheckbox) + .then(reload) + .then(register) + .then(testRegisterSuccesses) + .then(unregister) + .then(registerAndUnregisterInFrame) + .then(testRegisterSuccesses) + // Workers should be turned back off when we closes the toolbox + .then(toolbox.destroy.bind(toolbox)) + .then(reload) + .then(register) + .then(testRegisterFails) + .catch(function (e) { + ok(false, "Some test failed with error " + e); + }).then(finishUp); +} + +function finishUp() { + gBrowser.removeCurrentTab(); + toolbox = null; + finish(); +} diff --git a/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing_frame_script.js b/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing_frame_script.js new file mode 100644 index 000000000..ec5ab3762 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing_frame_script.js @@ -0,0 +1,46 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// A helper frame-script for devtools/client/framework service worker tests. + +"use strict"; + +addMessageListener("devtools:sw-test:register", function (msg) { + content.navigator.serviceWorker.register("serviceworker.js") + .then(swr => { + sendAsyncMessage("devtools:sw-test:register", {success: true}); + }, error => { + sendAsyncMessage("devtools:sw-test:register", {success: false}); + }); +}); + +addMessageListener("devtools:sw-test:unregister", function (msg) { + content.navigator.serviceWorker.getRegistration().then(swr => { + swr.unregister().then(result => { + sendAsyncMessage("devtools:sw-test:unregister", + {success: result ? true : false}); + }); + }); +}); + +addMessageListener("devtools:sw-test:iframe:register-and-unregister", function (msg) { + var frame = content.document.createElement("iframe"); + frame.addEventListener("load", function onLoad() { + frame.removeEventListener("load", onLoad); + frame.contentWindow.navigator.serviceWorker.register("serviceworker.js") + .then(swr => { + return swr.unregister(); + }).then(_ => { + frame.remove(); + sendAsyncMessage("devtools:sw-test:iframe:register-and-unregister", + {success: true}); + }).catch(error => { + sendAsyncMessage("devtools:sw-test:iframe:register-and-unregister", + {success: false}); + }); + }); + frame.src = "browser_toolbox_options_enabled_serviceworkers_testing.html"; + content.document.body.appendChild(frame); +}); 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 000000000..fedbc4402 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_races.js @@ -0,0 +1,81 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// 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"; + +add_task(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"); + + let tab = yield addTab(URL); + + let created = 0, ready = 0, destroy = 0, destroyed = 0; + let onCreated = () => { + created++; + }; + let onReady = () => { + ready++; + }; + let onDestroy = () => { + destroy++; + }; + let onDestroyed = () => { + destroyed++; + }; + gDevTools.on("toolbox-created", onCreated); + 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. + + // Toggle the toolbox at least 3 times. + info("Trying to toggle the toolbox 3 times"); + while (created < 3) { + // Sent multiple event to try to race the code during toolbox creation and destruction + toggle(); + toggle(); + toggle(); + + // Release the event loop to let a chance to actually create or destroy the toolbox! + yield wait(50); + } + info("Toggled the toolbox 3 times"); + + // Now wait for the 3rd toolbox to be fully ready before closing it. + // We close the last toolbox manually, out of the first while() loop to + // avoid races and be sure we end up we no toolbox and waited for all the + // requests to be done. + while (ready != 3) { + yield wait(100); + } + toggle(); + while (destroyed != 3) { + yield wait(100); + } + + is(created, 3, "right number of created events"); + is(ready, 3, "right number of ready events"); + is(destroy, 3, "right number of destroy events"); + is(destroyed, 3, "right number of destroyed events"); + + gDevTools.off("toolbox-created", onCreated); + gDevTools.off("toolbox-ready", onReady); + gDevTools.off("toolbox-destroy", onDestroy); + gDevTools.off("toolbox-destroyed", onDestroyed); + + gBrowser.removeCurrentTab(); +}); + +function toggle() { + EventUtils.synthesizeKey("VK_F12", {}); +} 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 000000000..0af1a4571 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_raise.js @@ -0,0 +1,78 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("devtools/client/framework/toolbox"); + +var toolbox, tab1, tab2; + +function test() { + addTab(TEST_URL).then(tab => { + tab2 = gBrowser.addTab(); + let target = TargetFactory.forTab(tab); + gDevTools.showToolbox(target) + .then(testBottomHost, console.error) + .then(null, console.error); + }); +} + +function testBottomHost(aToolbox) { + toolbox = aToolbox; + + // switch to another tab and test toolbox.raise() + gBrowser.selectedTab = tab2; + executeSoon(function () { + is(gBrowser.selectedTab, tab2, "Correct tab is selected before calling raise"); + toolbox.raise(); + executeSoon(function () { + is(gBrowser.selectedTab, tab1, "Correct tab was selected after calling raise"); + + toolbox.switchHost(Toolbox.HostType.WINDOW).then(testWindowHost).then(null, console.error); + }); + }); +} + +function testWindowHost() { + // Make sure toolbox is not focused. + window.addEventListener("focus", onFocus, true); + + // Need to wait for focus as otherwise window.focus() is overridden by + // toolbox window getting focused first on Linux and Mac. + let onToolboxFocus = () => { + toolbox.win.parent.removeEventListener("focus", onToolboxFocus, true); + info("focusing main window."); + window.focus(); + }; + // Need to wait for toolbox window to get focus. + toolbox.win.parent.addEventListener("focus", onToolboxFocus, true); +} + +function onFocus() { + info("Main window is focused before calling toolbox.raise()"); + window.removeEventListener("focus", onFocus, true); + + // Check if toolbox window got focus. + let onToolboxFocusAgain = () => { + toolbox.win.parent.removeEventListener("focus", onToolboxFocusAgain, false); + ok(true, "Toolbox window is the focused window after calling toolbox.raise()"); + cleanup(); + }; + toolbox.win.parent.addEventListener("focus", onToolboxFocusAgain, false); + + // Now raise toolbox. + toolbox.raise(); +} + +function cleanup() { + Services.prefs.setCharPref("devtools.toolbox.host", Toolbox.HostType.BOTTOM); + + toolbox.destroy().then(function () { + toolbox = null; + gBrowser.removeCurrentTab(); + gBrowser.removeCurrentTab(); + finish(); + }); +} 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 000000000..e1a59b3f0 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_ready.js @@ -0,0 +1,21 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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(function* () { + let tab = yield addTab(TEST_URL); + let target = TargetFactory.forTab(tab); + + const toolbox = yield gDevTools.showToolbox(target, "webconsole"); + ok(toolbox.isReady, "toolbox isReady is set"); + ok(toolbox.threadClient, "toolbox has a thread client"); + + const toolbox2 = yield gDevTools.showToolbox(toolbox.target, toolbox.toolId); + is(toolbox2, toolbox, "same toolbox"); + + yield 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 000000000..b30d633fa --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_remoteness_change.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var {Toolbox} = require("devtools/client/framework/toolbox"); + +const URL_1 = "about:robots"; +const URL_2 = "data:text/html;charset=UTF-8," + + encodeURIComponent("<div id=\"remote-page\">foo</div>"); + +add_task(function* () { + info("Open a tab on a URL supporting only running in parent process"); + let tab = yield 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"); + + let toolbox = yield openToolboxForTab(tab); + + let onToolboxDestroyed = toolbox.once("destroyed"); + let onToolboxCreated = gDevTools.once("toolbox-created"); + + info("Navigate to a URL supporting remote process"); + let onLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + gBrowser.loadURI(URL_2); + yield onLoaded; + + is(tab.linkedBrowser.getAttribute("remote"), "true", "Navigated to a data: URI and switching to remote"); + + info("Waiting for the toolbox to be destroyed"); + yield onToolboxDestroyed; + + info("Waiting for a new toolbox to be created"); + toolbox = yield onToolboxCreated; + + info("Waiting for the new toolbox to be ready"); + yield toolbox.once("ready"); + + info("Veryify we are inspecting the new document"); + let console = yield toolbox.selectTool("webconsole"); + let { jsterm } = console.hud; + let url = yield jsterm.execute("document.location.href"); + // Uses includes as the old console frontend prints a timestamp + ok(url.textContent.includes(URL_2), "The console inspects the second document"); +}); 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 000000000..ae104524e --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_select_event.js @@ -0,0 +1,101 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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(function* () { + let tab = yield addTab(PAGE_URL); + + let toolbox = yield openToolboxForTab(tab, "webconsole", "bottom"); + yield testSelectEvent("inspector"); + yield testSelectEvent("webconsole"); + yield testSelectEvent("styleeditor"); + yield testSelectEvent("inspector"); + yield testSelectEvent("webconsole"); + yield testSelectEvent("styleeditor"); + + yield testToolSelectEvent("inspector"); + yield testToolSelectEvent("webconsole"); + yield testToolSelectEvent("styleeditor"); + yield toolbox.destroy(); + + toolbox = yield openToolboxForTab(tab, "webconsole", "side"); + yield testSelectEvent("inspector"); + yield testSelectEvent("webconsole"); + yield testSelectEvent("styleeditor"); + yield testSelectEvent("inspector"); + yield testSelectEvent("webconsole"); + yield testSelectEvent("styleeditor"); + yield toolbox.destroy(); + + toolbox = yield openToolboxForTab(tab, "webconsole", "window"); + yield testSelectEvent("inspector"); + yield testSelectEvent("webconsole"); + yield testSelectEvent("styleeditor"); + yield testSelectEvent("inspector"); + yield testSelectEvent("webconsole"); + yield testSelectEvent("styleeditor"); + yield toolbox.destroy(); + + yield testSelectToolRace(); + + /** + * Assert that selecting the given toolId raises a select event + * @param {toolId} Id of the tool to test + */ + function* testSelectEvent(toolId) { + let onSelect = toolbox.once("select"); + toolbox.selectTool(toolId); + let id = yield 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 + */ + function* testToolSelectEvent(toolId) { + let onSelected = toolbox.once(toolId + "-selected"); + toolbox.selectTool(toolId); + yield onSelected; + is(toolbox.currentToolId, toolId, toolId + " tool selected"); + } + + /** + * Assert that two calls to selectTool won't race + */ + function* testSelectToolRace() { + let toolbox = yield openToolboxForTab(tab, "webconsole"); + let selected = false; + let onSelect = (event, id) => { + if (selected) { + ok(false, "Got more than one 'select' event"); + } else { + selected = true; + } + }; + toolbox.once("select", onSelect); + let p1 = toolbox.selectTool("inspector") + let p2 = toolbox.selectTool("inspector"); + // Check that both promises don't resolve too early + let checkSelectToolResolution = panel => { + ok(selected, "selectTool resolves only after 'select' event is fired"); + let inspector = toolbox.getPanel("inspector"); + is(panel, inspector, "selecTool resolves to the panel instance"); + }; + p1.then(checkSelectToolResolution); + p2.then(checkSelectToolResolution); + yield p1; + yield p2; + + yield 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 000000000..d7cc5c94d --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_selected_tool_unavailable.js @@ -0,0 +1,48 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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: "test-tool", + isTargetSupported: () => true, + visibilityswitch: "devtools.test-tool.enabled", + url: "about:blank", + label: "someLabel", + build: (iframeWindow, toolbox) => { + return { + target: toolbox.target, + toolbox: toolbox, + isReady: true, + destroy: () => {}, + panelDoc: iframeWindow.document + }; + } +}; + +add_task(function* () { + gDevTools.registerTool(testToolDefinition); + let tab = yield addTab("about:blank"); + let target = TargetFactory.forTab(tab); + + let toolbox = yield gDevTools.showToolbox(target, testToolDefinition.id); + is(toolbox.currentToolId, "test-tool", "test-tool was selected"); + yield gDevTools.closeToolbox(target); + + // Make the previously selected tool unavailable. + testToolDefinition.isTargetSupported = () => false; + + target = TargetFactory.forTab(tab); + toolbox = yield gDevTools.showToolbox(target); + is(toolbox.currentToolId, "webconsole", "web console was selected"); + + yield gDevTools.closeToolbox(target); + gDevTools.unregisterTool(testToolDefinition.id); + tab = toolbox = target = null; + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_sidebar.js b/devtools/client/framework/test/browser_toolbox_sidebar.js new file mode 100644 index 000000000..897f8cba5 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_sidebar.js @@ -0,0 +1,181 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function test() { + const Cu = Components.utils; + let {ToolSidebar} = require("devtools/client/framework/sidebar"); + + const toolURL = "data:text/xml;charset=utf8,<?xml version='1.0'?>" + + "<window xmlns='http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'>" + + "<hbox flex='1'><description flex='1'>foo</description><splitter class='devtools-side-splitter'/>" + + "<tabbox flex='1' id='sidebar' class='devtools-sidebar-tabs'><tabs/><tabpanels flex='1'/></tabbox>" + + "</hbox>" + + "</window>"; + + const tab1URL = "data:text/html;charset=utf8,<title>1</title><p>1</p>"; + const tab2URL = "data:text/html;charset=utf8,<title>2</title><p>2</p>"; + const tab3URL = "data:text/html;charset=utf8,<title>3</title><p>3</p>"; + + let panelDoc; + let tab1Selected = false; + let registeredTabs = {}; + let readyTabs = {}; + + let toolDefinition = { + id: "fakeTool4242", + visibilityswitch: "devtools.fakeTool4242.enabled", + url: toolURL, + label: "FAKE TOOL!!!", + isTargetSupported: () => true, + build: function (iframeWindow, toolbox) { + let deferred = defer(); + executeSoon(() => { + deferred.resolve({ + target: toolbox.target, + toolbox: toolbox, + isReady: true, + destroy: function () {}, + panelDoc: iframeWindow.document, + }); + }); + return deferred.promise; + }, + }; + + gDevTools.registerTool(toolDefinition); + + addTab("about:blank").then(function (aTab) { + let target = TargetFactory.forTab(aTab); + gDevTools.showToolbox(target, toolDefinition.id).then(function (toolbox) { + let panel = toolbox.getPanel(toolDefinition.id); + panel.toolbox = toolbox; + ok(true, "Tool open"); + + let tabbox = panel.panelDoc.getElementById("sidebar"); + panel.sidebar = new ToolSidebar(tabbox, panel, "testbug865688", true); + + panel.sidebar.on("new-tab-registered", function (event, id) { + registeredTabs[id] = true; + }); + + panel.sidebar.once("tab1-ready", function (event) { + info(event); + readyTabs.tab1 = true; + allTabsReady(panel); + }); + + panel.sidebar.once("tab2-ready", function (event) { + info(event); + readyTabs.tab2 = true; + allTabsReady(panel); + }); + + panel.sidebar.once("tab3-ready", function (event) { + info(event); + readyTabs.tab3 = true; + allTabsReady(panel); + }); + + panel.sidebar.once("tab1-selected", function (event) { + info(event); + tab1Selected = true; + allTabsReady(panel); + }); + + panel.sidebar.addTab("tab1", tab1URL, {selected: true}); + panel.sidebar.addTab("tab2", tab2URL); + panel.sidebar.addTab("tab3", tab3URL); + + panel.sidebar.show(); + }).then(null, console.error); + }); + + function allTabsReady(panel) { + if (!tab1Selected || !readyTabs.tab1 || !readyTabs.tab2 || !readyTabs.tab3) { + return; + } + + ok(registeredTabs.tab1, "tab1 registered"); + ok(registeredTabs.tab2, "tab2 registered"); + ok(registeredTabs.tab3, "tab3 registered"); + ok(readyTabs.tab1, "tab1 ready"); + ok(readyTabs.tab2, "tab2 ready"); + ok(readyTabs.tab3, "tab3 ready"); + + let tabs = panel.sidebar._tabbox.querySelectorAll("tab"); + let panels = panel.sidebar._tabbox.querySelectorAll("tabpanel"); + let label = 1; + for (let tab of tabs) { + is(tab.getAttribute("label"), label++, "Tab has the right title"); + } + + is(label, 4, "Found the right amount of tabs."); + is(panel.sidebar._tabbox.selectedPanel, panels[0], "First tab is selected"); + is(panel.sidebar.getCurrentTabID(), "tab1", "getCurrentTabID() is correct"); + + panel.sidebar.once("tab1-unselected", function () { + ok(true, "received 'unselected' event"); + panel.sidebar.once("tab2-selected", function () { + ok(true, "received 'selected' event"); + tabs[1].focus(); + is(panel.sidebar._panelDoc.activeElement, tabs[1], + "Focus is set to second tab"); + panel.sidebar.hide(); + isnot(panel.sidebar._panelDoc.activeElement, tabs[1], + "Focus is reset for sidebar"); + is(panel.sidebar._tabbox.getAttribute("hidden"), "true", "Sidebar hidden"); + is(panel.sidebar.getWindowForTab("tab1").location.href, tab1URL, "Window is accessible"); + testRemoval(panel); + }); + }); + + panel.sidebar.select("tab2"); + } + + function testRemoval(panel) { + panel.sidebar.once("tab-unregistered", function (event, id) { + info(event); + registeredTabs[id] = false; + + is(id, "tab3", "The right tab must be removed"); + + let tabs = panel.sidebar._tabbox.querySelectorAll("tab"); + let panels = panel.sidebar._tabbox.querySelectorAll("tabpanel"); + + is(tabs.length, 2, "There is the right number of tabs"); + is(panels.length, 2, "There is the right number of panels"); + + testWidth(panel); + }); + + panel.sidebar.removeTab("tab3"); + } + + function testWidth(panel) { + let tabbox = panel.panelDoc.getElementById("sidebar"); + tabbox.width = 420; + panel.sidebar.destroy().then(function () { + tabbox.width = 0; + panel.sidebar = new ToolSidebar(tabbox, panel, "testbug865688", true); + panel.sidebar.show(); + is(panel.panelDoc.getElementById("sidebar").width, 420, "Width restored"); + + finishUp(panel); + }); + } + + function finishUp(panel) { + panel.sidebar.destroy(); + panel.toolbox.destroy().then(function () { + gDevTools.unregisterTool(toolDefinition.id); + + gBrowser.removeCurrentTab(); + + executeSoon(function () { + finish(); + }); + }); + } +} diff --git a/devtools/client/framework/test/browser_toolbox_sidebar_events.js b/devtools/client/framework/test/browser_toolbox_sidebar_events.js new file mode 100644 index 000000000..9137aaebe --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_sidebar_events.js @@ -0,0 +1,93 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function test() { + const Cu = Components.utils; + const { ToolSidebar } = require("devtools/client/framework/sidebar"); + + const toolURL = "data:text/xml;charset=utf8,<?xml version='1.0'?>" + + "<window xmlns='http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'>" + + "<hbox flex='1'><description flex='1'>foo</description><splitter class='devtools-side-splitter'/>" + + "<tabbox flex='1' id='sidebar' class='devtools-sidebar-tabs'><tabs/><tabpanels flex='1'/></tabbox>" + + "</hbox>" + + "</window>"; + + const tab1URL = "data:text/html;charset=utf8,<title>1</title><p>1</p>"; + + let collectedEvents = []; + + let toolDefinition = { + id: "testTool1072208", + visibilityswitch: "devtools.testTool1072208.enabled", + url: toolURL, + label: "Test tool", + isTargetSupported: () => true, + build: function (iframeWindow, toolbox) { + let deferred = defer(); + executeSoon(() => { + deferred.resolve({ + target: toolbox.target, + toolbox: toolbox, + isReady: true, + destroy: function () {}, + panelDoc: iframeWindow.document, + }); + }); + return deferred.promise; + }, + }; + + gDevTools.registerTool(toolDefinition); + + addTab("about:blank").then(function (aTab) { + let target = TargetFactory.forTab(aTab); + gDevTools.showToolbox(target, toolDefinition.id).then(function (toolbox) { + let panel = toolbox.getPanel(toolDefinition.id); + ok(true, "Tool open"); + + panel.once("sidebar-created", function (event, id) { + collectedEvents.push(event); + }); + + panel.once("sidebar-destroyed", function (event, id) { + collectedEvents.push(event); + }); + + let tabbox = panel.panelDoc.getElementById("sidebar"); + panel.sidebar = new ToolSidebar(tabbox, panel, "testbug1072208", true); + + panel.sidebar.once("show", function (event, id) { + collectedEvents.push(event); + }); + + panel.sidebar.once("hide", function (event, id) { + collectedEvents.push(event); + }); + + panel.sidebar.once("tab1-selected", () => finishUp(panel)); + panel.sidebar.addTab("tab1", tab1URL, {selected: true}); + panel.sidebar.show(); + }).then(null, console.error); + }); + + function finishUp(panel) { + panel.sidebar.hide(); + panel.sidebar.destroy(); + + let events = collectedEvents.join(":"); + is(events, "sidebar-created:show:hide:sidebar-destroyed", + "Found the right amount of collected events."); + + panel.toolbox.destroy().then(function () { + gDevTools.unregisterTool(toolDefinition.id); + gBrowser.removeCurrentTab(); + + executeSoon(function () { + finish(); + }); + }); + } +} + diff --git a/devtools/client/framework/test/browser_toolbox_sidebar_existing_tabs.js b/devtools/client/framework/test/browser_toolbox_sidebar_existing_tabs.js new file mode 100644 index 000000000..339687e10 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_sidebar_existing_tabs.js @@ -0,0 +1,78 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the sidebar widget auto-registers existing tabs. + +const {ToolSidebar} = require("devtools/client/framework/sidebar"); + +const testToolURL = "data:text/xml;charset=utf8,<?xml version='1.0'?>" + + "<window xmlns='http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'>" + + "<hbox flex='1'><description flex='1'>test tool</description>" + + "<splitter class='devtools-side-splitter'/>" + + "<tabbox flex='1' id='sidebar' class='devtools-sidebar-tabs'>" + + "<tabs><tab id='tab1' label='tab 1'></tab><tab id='tab2' label='tab 2'></tab></tabs>" + + "<tabpanels flex='1'><tabpanel id='tabpanel1'>tab 1</tabpanel><tabpanel id='tabpanel2'>tab 2</tabpanel></tabpanels>" + + "</tabbox></hbox></window>"; + +const testToolDefinition = { + id: "testTool", + url: testToolURL, + label: "Test Tool", + isTargetSupported: () => true, + build: (iframeWindow, toolbox) => { + return promise.resolve({ + target: toolbox.target, + toolbox: toolbox, + isReady: true, + destroy: () => {}, + panelDoc: iframeWindow.document, + }); + } +}; + +add_task(function* () { + let tab = yield addTab("about:blank"); + + let target = TargetFactory.forTab(tab); + + gDevTools.registerTool(testToolDefinition); + let toolbox = yield gDevTools.showToolbox(target, testToolDefinition.id); + + let toolPanel = toolbox.getPanel(testToolDefinition.id); + let tabbox = toolPanel.panelDoc.getElementById("sidebar"); + + info("Creating the sidebar widget"); + let sidebar = new ToolSidebar(tabbox, toolPanel, "bug1101569"); + + info("Checking that existing tabs have been registered"); + ok(sidebar.getTab("tab1"), "Existing tab 1 was found"); + ok(sidebar.getTab("tab2"), "Existing tab 2 was found"); + ok(sidebar.getTabPanel("tabpanel1"), "Existing tabpanel 1 was found"); + ok(sidebar.getTabPanel("tabpanel2"), "Existing tabpanel 2 was found"); + + info("Checking that the sidebar API works with existing tabs"); + + sidebar.select("tab2"); + is(tabbox.selectedTab, tabbox.querySelector("#tab2"), + "Existing tabs can be selected"); + + sidebar.select("tab1"); + is(tabbox.selectedTab, tabbox.querySelector("#tab1"), + "Existing tabs can be selected"); + + is(sidebar.getCurrentTabID(), "tab1", "getCurrentTabID returns the expected id"); + + info("Removing a tab"); + sidebar.removeTab("tab2", "tabpanel2"); + ok(!sidebar.getTab("tab2"), "Tab 2 was removed correctly"); + ok(!sidebar.getTabPanel("tabpanel2"), "Tabpanel 2 was removed correctly"); + + sidebar.destroy(); + yield toolbox.destroy(); + gDevTools.unregisterTool(testToolDefinition.id); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_sidebar_overflow_menu.js b/devtools/client/framework/test/browser_toolbox_sidebar_overflow_menu.js new file mode 100644 index 000000000..5f6914a2f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_sidebar_overflow_menu.js @@ -0,0 +1,80 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the sidebar widget correctly displays the "all tabs..." button +// when the tabs overflow. + +const {ToolSidebar} = require("devtools/client/framework/sidebar"); + +const testToolDefinition = { + id: "testTool", + url: CHROME_URL_ROOT + "browser_toolbox_sidebar_tool.xul", + label: "Test Tool", + isTargetSupported: () => true, + build: (iframeWindow, toolbox) => { + return { + target: toolbox.target, + toolbox: toolbox, + isReady: true, + destroy: () => {}, + panelDoc: iframeWindow.document, + }; + } +}; + +add_task(function* () { + let tab = yield addTab("about:blank"); + let target = TargetFactory.forTab(tab); + + gDevTools.registerTool(testToolDefinition); + let toolbox = yield gDevTools.showToolbox(target, testToolDefinition.id); + + let toolPanel = toolbox.getPanel(testToolDefinition.id); + let tabbox = toolPanel.panelDoc.getElementById("sidebar"); + + info("Creating the sidebar widget"); + let sidebar = new ToolSidebar(tabbox, toolPanel, "bug1101569", { + showAllTabsMenu: true + }); + + let allTabsMenu = toolPanel.panelDoc.querySelector(".devtools-sidebar-alltabs"); + ok(allTabsMenu, "The all-tabs menu is available"); + is(allTabsMenu.getAttribute("hidden"), "true", "The menu is hidden for now"); + + info("Adding 10 tabs to the sidebar widget"); + for (let nb = 0; nb < 10; nb++) { + let url = `data:text/html;charset=utf8,<title>tab ${nb}</title><p>Test tab ${nb}</p>`; + sidebar.addTab("tab" + nb, url, {selected: nb === 0}); + } + + info("Fake an overflow event so that the all-tabs menu is visible"); + sidebar._onTabBoxOverflow(); + ok(!allTabsMenu.hasAttribute("hidden"), "The all-tabs menu is now shown"); + + info("Select each tab, one by one"); + for (let nb = 0; nb < 10; nb++) { + let id = "tab" + nb; + + info("Found tab item nb " + nb); + let item = allTabsMenu.querySelector("#sidebar-alltabs-item-" + id); + + info("Click on the tab"); + EventUtils.sendMouseEvent({type: "click"}, item, toolPanel.panelDoc.defaultView); + + is(tabbox.selectedTab.id, "sidebar-tab-" + id, + "The selected tab is now nb " + nb); + } + + info("Fake an underflow event so that the all-tabs menu gets hidden"); + sidebar._onTabBoxUnderflow(); + is(allTabsMenu.getAttribute("hidden"), "true", "The all-tabs menu is hidden"); + + yield sidebar.destroy(); + yield toolbox.destroy(); + gDevTools.unregisterTool(testToolDefinition.id); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_sidebar_tool.xul b/devtools/client/framework/test/browser_toolbox_sidebar_tool.xul new file mode 100644 index 000000000..2ce495158 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_sidebar_tool.xul @@ -0,0 +1,18 @@ +<?xml version="1.0"?> +<!-- 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/. --> +<?xml-stylesheet href="chrome://global/skin/" type="text/css"?> +<?xml-stylesheet href="chrome://devtools/content/shared/widgets/widgets.css" type="text/css"?> +<?xml-stylesheet href="chrome://devtools/skin/widgets.css" type="text/css"?> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script type="application/javascript;version=1.8" src="chrome://devtools/content/shared/theme-switching.js"/> + <box flex="1" class="devtools-responsive-container theme-body"> + <vbox flex="1" class="devtools-main-content" id="content">test</vbox> + <splitter class="devtools-side-splitter"/> + <tabbox flex="1" id="sidebar" class="devtools-sidebar-tabs"> + <tabs/> + <tabpanels flex="1"/> + </tabbox> + </box> +</window> 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 000000000..8e1fecd15 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_split_console.js @@ -0,0 +1,85 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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"; + +// Force the old debugger UI since it's directly used (see Bug 1301705) +Services.prefs.setBoolPref("devtools.debugger.new-debugger-frontend", false); +registerCleanupFunction(function* () { + Services.prefs.clearUserPref("devtools.debugger.new-debugger-frontend"); +}); + +add_task(function* () { + let tab = yield addTab(URL); + let target = TargetFactory.forTab(tab); + gToolbox = yield gDevTools.showToolbox(target, "jsdebugger"); + panelWin = gToolbox.getPanel("jsdebugger").panelWin; + + yield gToolbox.openSplitConsole(); + yield testIsSplitConsoleFocused(); + yield testUseKeyWithSplitConsole(); + yield testUseKeyWithSplitConsoleWrongTool(); + + yield cleanup(); +}); + +function* testIsSplitConsoleFocused() { + yield 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"); + let consoleInput = gToolbox.getPanel("webconsole").hud.jsterm.inputNode; + consoleInput.focus(); + 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"); + let consoleInput = gToolbox.getPanel("webconsole").hud.jsterm.inputNode; + consoleInput.focus(); + synthesizeKeyShortcut("F4", panelWin); + + ok(!commandCalled, "Shortcut key shouldn't trigger the command"); +} + +function* cleanup() { + // We don't want the open split console to confuse other tests.. + Services.prefs.clearUserPref("devtools.toolbox.splitconsoleEnabled"); + yield gToolbox.destroy(); + gBrowser.removeCurrentTab(); + gToolbox = panelWin = null; +} 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 000000000..b9401f768 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_tabsswitch_shortcuts.js @@ -0,0 +1,68 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +var {Toolbox} = require("devtools/client/framework/toolbox"); + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties"); + +add_task(function* () { + let tab = yield addTab("about:blank"); + let target = TargetFactory.forTab(tab); + yield target.makeRemote(); + + let toolIDs = gDevTools.getToolDefinitionArray() + .filter(def => def.isTargetSupported(target)) + .map(def => def.id); + + let toolbox = yield gDevTools.showToolbox(target, toolIDs[0], Toolbox.HostType.BOTTOM); + let nextShortcut = L10N.getStr("toolbox.nextTool.key"); + let 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++) { + yield 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++) { + yield 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--) { + yield 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--) { + yield testShortcuts(toolbox, i, prevShortcut, toolIDs); + } + + yield toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +function* testShortcuts(toolbox, index, shortcut, toolIDs) { + info("Testing shortcut to switch to tool " + index + ":" + toolIDs[index] + + " using shortcut " + shortcut); + + let onToolSelected = toolbox.once("select"); + synthesizeKeyShortcut(shortcut); + let id = yield 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_target.js b/devtools/client/framework/test/browser_toolbox_target.js new file mode 100644 index 000000000..68639c501 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_target.js @@ -0,0 +1,60 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test about:devtools-toolbox?target which allows opening a toolbox in an +// iframe while defining which document to debug by setting a `target` +// attribute refering to the document to debug. + +add_task(function *() { + // iframe loads the document to debug + let iframe = document.createElement("browser"); + iframe.setAttribute("type", "content"); + document.documentElement.appendChild(iframe); + + let onLoad = once(iframe, "load", true); + iframe.setAttribute("src", "data:text/html,document to debug"); + yield onLoad; + is(iframe.contentWindow.document.body.innerHTML, "document to debug"); + + // toolbox loads the toolbox document + let toolboxIframe = document.createElement("iframe"); + document.documentElement.appendChild(toolboxIframe); + + // Important step to define which target to debug + toolboxIframe.target = iframe; + + let onToolboxReady = gDevTools.once("toolbox-ready"); + + onLoad = once(toolboxIframe, "load", true); + toolboxIframe.setAttribute("src", "about:devtools-toolbox?target"); + yield onLoad; + + // Also wait for toolbox-ready, as toolbox document load isn't enough, there + // is plenty of asynchronous steps during toolbox load + info("Waiting for toolbox-ready"); + let toolbox = yield onToolboxReady; + + let onToolboxDestroyed = gDevTools.once("toolbox-destroyed"); + let onTabActorDetached = once(toolbox.target.client, "tabDetached"); + + info("Removing the iframes"); + toolboxIframe.remove(); + + // And wait for toolbox-destroyed as toolbox unload is also full of + // asynchronous operation that outlast unload event + info("Waiting for toolbox-destroyed"); + yield onToolboxDestroyed; + info("Toolbox destroyed"); + + // Also wait for tabDetached. Toolbox destroys the Target which calls + // TabActor.detach(). But Target doesn't wait for detach's end to resolve. + // Whereas it is quite important as it is a significant part of toolbox + // cleanup. If we do not wait for it and starts removing debugged document, + // the actor is still considered as being attached and continues processing + // events. + yield onTabActorDetached; + + iframe.remove(); +}); 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 000000000..2e5f3210e --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_textbox_context_menu.js @@ -0,0 +1,55 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const URL = "data:text/html;charset=utf8,test for textbox context menu"; + +add_task(function* () { + let toolbox = yield openNewTabAndToolbox(URL, "inspector"); + let textboxContextMenu = toolbox.textBoxContextMenuPopup; + + emptyClipboard(); + + // Make sure the focus is predictable. + let inspector = toolbox.getPanel("inspector"); + let onFocus = once(inspector.searchBox, "focus"); + inspector.searchBox.focus(); + yield onFocus; + + ok(textboxContextMenu, "The textbox context menu is loaded in the toolbox"); + + let cmdUndo = textboxContextMenu.querySelector("[command=cmd_undo]"); + let cmdDelete = textboxContextMenu.querySelector("[command=cmd_delete]"); + let cmdSelectAll = textboxContextMenu.querySelector("[command=cmd_selectAll]"); + let cmdCut = textboxContextMenu.querySelector("[command=cmd_cut]"); + let cmdCopy = textboxContextMenu.querySelector("[command=cmd_copy]"); + let cmdPaste = textboxContextMenu.querySelector("[command=cmd_paste]"); + + info("Opening context menu"); + + let onContextMenuPopup = once(textboxContextMenu, "popupshowing"); + textboxContextMenu.openPopupAtScreen(0, 0, true); + yield onContextMenuPopup; + + is(cmdUndo.getAttribute("disabled"), "true", "cmdUndo is disabled"); + is(cmdDelete.getAttribute("disabled"), "true", "cmdDelete is disabled"); + is(cmdSelectAll.getAttribute("disabled"), "true", "cmdSelectAll is disabled"); + + // Cut/Copy items are enabled in context menu even if there + // is no selection. See also Bug 1303033 + is(cmdCut.getAttribute("disabled"), "", "cmdCut is enabled"); + is(cmdCopy.getAttribute("disabled"), "", "cmdCopy is enabled"); + + if (isWindows()) { + // emptyClipboard only works on Windows (666254), assert paste only for this OS. + is(cmdPaste.getAttribute("disabled"), "true", "cmdPaste is disabled"); + } + + yield cleanup(toolbox); +}); + +function* cleanup(toolbox) { + yield toolbox.destroy(); + gBrowser.removeCurrentTab(); +} 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 000000000..7794d457c --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_theme_registration.js @@ -0,0 +1,102 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from shared-head.js */ +"use strict"; + +// Test for dynamically registering and unregistering themes +const CHROME_URL = "chrome://mochitests/content/browser/devtools/client/framework/test/"; + +var toolbox; + +add_task(function* themeRegistration() { + let tab = yield addTab("data:text/html,test"); + let target = TargetFactory.forTab(tab); + toolbox = yield gDevTools.showToolbox(target, "options"); + + let themeId = yield new Promise(resolve => { + gDevTools.once("theme-registered", (e, registeredThemeId) => { + resolve(registeredThemeId); + }); + + gDevTools.registerTheme({ + id: "test-theme", + label: "Test theme", + stylesheets: [CHROME_URL + "doc_theme.css"], + classList: ["theme-test"], + }); + }); + + is(themeId, "test-theme", "theme-registered event handler sent theme id"); + + ok(gDevTools.getThemeDefinitionMap().has(themeId), "theme added to map"); +}); + +add_task(function* themeInOptionsPanel() { + let panelWin = toolbox.getCurrentPanel().panelWin; + let doc = panelWin.frameElement.contentDocument; + let themeBox = doc.getElementById("devtools-theme-box"); + let testThemeOption = themeBox.querySelector( + "input[type=radio][value=test-theme]"); + + ok(testThemeOption, "new theme exists in the Options panel"); + + let lightThemeOption = themeBox.querySelector( + "input[type=radio][value=light]"); + + 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"); + yield onThemeSwithComplete; + + 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"); + yield onThemeSwithComplete; + + 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(); + yield onThemeSwithComplete; +}); + +add_task(function* themeUnregistration() { + let panelWin = toolbox.getCurrentPanel().panelWin; + let onUnRegisteredTheme = once(gDevTools, "theme-unregistered"); + let onThemeSwitchComplete = once(panelWin, "theme-switch-complete"); + gDevTools.unregisterTheme("test-theme"); + yield onUnRegisteredTheme; + yield onThemeSwitchComplete; + + ok(!gDevTools.getThemeDefinitionMap().has("test-theme"), + "theme removed from map"); + + let doc = panelWin.frameElement.contentDocument; + let themeBox = doc.getElementById("devtools-theme-box"); + + // The default light theme must be selected now. + is(themeBox.querySelector("#devtools-theme-box [value=light]").checked, true, + "light theme must be selected"); +}); + +add_task(function* cleanup() { + yield 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 000000000..d5b6d0e96 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toggle.js @@ -0,0 +1,108 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("devtools/client/framework/toolbox"); + +add_task(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.xul + // - key_devToolboxMenuItem in browser/base/content/browser.xul + info("Test toggle using CTRL+SHIFT+I/CMD+ALT+I"); + yield testToggle("I", { + accelKey: true, + shiftKey: !navigator.userAgent.match(/Mac/), + altKey: navigator.userAgent.match(/Mac/) + }); + + // Test with F12 ; no modifiers + info("Test toggle using F12"); + yield testToggle("VK_F12", {}); +}); + +function* testToggle(key, modifiers) { + let tab = yield addTab(URL + " ; key : '" + key + "'"); + yield gDevTools.showToolbox(TargetFactory.forTab(tab)); + + yield testToggleDockedToolbox(tab, key, modifiers); + yield testToggleDetachedToolbox(tab, key, modifiers); + + yield cleanup(); +} + +function* testToggleDockedToolbox(tab, key, modifiers) { + let toolbox = 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"); + let onToolboxDestroyed = once(gDevTools, "toolbox-destroyed"); + EventUtils.synthesizeKey(key, modifiers); + yield onToolboxDestroyed; + ok(true, "Docked toolbox is destroyed when using a toggle key"); + + info("verify new toolbox is created when using toggle key"); + let onToolboxReady = once(gDevTools, "toolbox-ready"); + EventUtils.synthesizeKey(key, modifiers); + yield onToolboxReady; + ok(true, "Toolbox is created by using when toggle key"); +} + +function* testToggleDetachedToolbox(tab, key, modifiers) { + let toolbox = getToolboxForTab(tab); + + info("change the toolbox hostType to WINDOW"); + + yield toolbox.switchHost(Toolbox.HostType.WINDOW); + is(toolbox.hostType, Toolbox.HostType.WINDOW, + "Toolbox opened on separate window"); + + info("Wait for focus on the toolbox window"); + yield new Promise(res => waitForFocus(res, toolbox.win)); + + info("Focus main window to put the toolbox window in the background"); + + let onMainWindowFocus = once(window, "focus"); + window.focus(); + yield onMainWindowFocus; + ok(true, "Main window focused"); + + info("Verify windowed toolbox is focused instead of closed when using " + + "toggle key from the main window"); + let toolboxWindow = toolbox.win.top; + let onToolboxWindowFocus = once(toolboxWindow, "focus", true); + EventUtils.synthesizeKey(key, modifiers); + yield onToolboxWindowFocus; + ok(true, "Toolbox focused and not destroyed"); + + info("Verify windowed toolbox is destroyed when using toggle key from its " + + "own window"); + + let onToolboxDestroyed = once(gDevTools, "toolbox-destroyed"); + EventUtils.synthesizeKey(key, modifiers, toolboxWindow); + yield onToolboxDestroyed; + ok(true, "Toolbox destroyed"); +} + +function getToolboxForTab(tab) { + return gDevTools.getToolbox(TargetFactory.forTab(tab)); +} + +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 000000000..7d430e7c5 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_tool_ready.js @@ -0,0 +1,51 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(5); + +/** + * Whitelisting this test. + * As part of bug 1077403, the leaking uncaught rejection should be fixed. + */ +thisTestLeaksUncaughtRejectionsAndShouldBeFixed("Error: Shader Editor is " + + "still waiting for a WebGL context to be created."); + +function performChecks(target) { + return Task.spawn(function* () { + let toolIds = gDevTools.getToolDefinitionArray() + .filter(def => def.isTargetSupported(target)) + .map(def => def.id); + + let toolbox; + for (let index = 0; index < toolIds.length; index++) { + let toolId = toolIds[index]; + + info("About to open " + index + "/" + toolId); + toolbox = yield gDevTools.showToolbox(target, toolId); + ok(toolbox, "toolbox exists for " + toolId); + is(toolbox.currentToolId, toolId, "currentToolId should be " + toolId); + + let panel = toolbox.getCurrentPanel(); + ok(panel.isReady, toolId + " panel should be ready"); + } + + yield toolbox.destroy(); + }); +} + +function test() { + Task.spawn(function* () { + toggleAllTools(true); + let tab = yield addTab("about:blank"); + let target = TargetFactory.forTab(tab); + yield target.makeRemote(); + yield performChecks(target); + gBrowser.removeCurrentTab(); + toggleAllTools(false); + finish(); + }, console.error); +} 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 000000000..03461e953 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_tool_remote_reopen.js @@ -0,0 +1,135 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Whitelisting this test. + * As part of bug 1077403, the leaking uncaught rejection should be fixed. + */ +thisTestLeaksUncaughtRejectionsAndShouldBeFixed("Error: Shader Editor is " + + "still waiting for a WebGL context to be created."); + +const { DebuggerServer } = require("devtools/server/main"); +const { DebuggerClient } = require("devtools/shared/client/main"); + +// 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 DebuggerClient 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 DebuggerClient 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 WebIDE, we do not destroy the DebuggerClient on toolbox close because it + * is still used for other purposes like managing apps, etc. that aren't part of + * a toolbox. Thus, the same client gets reused across multiple toolboxes, + * which leads to the tools failing if they don't destroy their fronts. + */ + +function runTools(target) { + return Task.spawn(function* () { + let toolIds = gDevTools.getToolDefinitionArray() + .filter(def => def.isTargetSupported(target)) + .map(def => def.id); + + let toolbox; + for (let index = 0; index < toolIds.length; index++) { + let toolId = toolIds[index]; + + info("About to open " + index + "/" + toolId); + toolbox = yield gDevTools.showToolbox(target, toolId, "window"); + ok(toolbox, "toolbox exists for " + toolId); + is(toolbox.currentToolId, toolId, "currentToolId should be " + toolId); + + let panel = toolbox.getCurrentPanel(); + ok(panel.isReady, toolId + " panel should be ready"); + } + + yield toolbox.destroy(); + }); +} + +function getClient() { + let deferred = defer(); + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + + let transport = DebuggerServer.connectPipe(); + let client = new DebuggerClient(transport); + + return client.connect().then(() => client); +} + +function getTarget(client) { + let deferred = defer(); + + client.listTabs(tabList => { + let target = TargetFactory.forRemoteTab({ + client: client, + form: tabList.tabs[tabList.selected], + chrome: false + }); + deferred.resolve(target); + }); + + return deferred.promise; +} + +function test() { + Task.spawn(function* () { + toggleAllTools(true); + yield addTab("about:blank"); + + let client = yield getClient(); + let target = yield getTarget(client); + yield runTools(target); + + // Actor fronts should be destroyed now that the toolbox has closed, but + // look for any that remain. + for (let pool of client.__pools) { + if (!pool.__poolMap) { + continue; + } + for (let actor of pool.__poolMap.keys()) { + // Bug 1056342: Profiler fails today because of framerate actor, but + // this appears more complex to rework, so leave it for that bug to + // resolve. + if (actor.includes("framerateActor")) { + todo(false, "Front for " + actor + " still held in pool!"); + continue; + } + // gcliActor is for the commandline which is separate to the toolbox + if (actor.includes("gcliActor")) { + continue; + } + ok(false, "Front for " + actor + " still held in pool!"); + } + } + + gBrowser.removeCurrentTab(); + DebuggerServer.destroy(); + toggleAllTools(false); + finish(); + }, console.error); +} diff --git a/devtools/client/framework/test/browser_toolbox_transport_events.js b/devtools/client/framework/test/browser_toolbox_transport_events.js new file mode 100644 index 000000000..1e2b67ac4 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_transport_events.js @@ -0,0 +1,108 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { on, off } = require("sdk/event/core"); +const { DebuggerClient } = require("devtools/shared/client/main"); + +function test() { + gDevTools.on("toolbox-created", onToolboxCreated); + on(DebuggerClient, "connect", onDebuggerClientConnect); + + addTab("about:blank").then(function () { + let target = TargetFactory.forTab(gBrowser.selectedTab); + gDevTools.showToolbox(target, "webconsole").then(testResults); + }); +} + +function testResults(toolbox) { + testPackets(sent1, received1); + testPackets(sent2, received2); + + cleanUp(toolbox); +} + +function cleanUp(toolbox) { + gDevTools.off("toolbox-created", onToolboxCreated); + off(DebuggerClient, "connect", onDebuggerClientConnect); + + toolbox.destroy().then(function () { + gBrowser.removeCurrentTab(); + executeSoon(function () { + finish(); + }); + }); +} + +function testPackets(sent, received) { + ok(sent.length > 0, "There must be at least one sent packet"); + ok(received.length > 0, "There must be at leaset one received packet"); + + if (!sent.length || received.length) { + return; + } + + let sentPacket = sent[0]; + let receivedPacket = received[0]; + + is(receivedPacket.from, "root", + "The first received packet is from the root"); + is(receivedPacket.applicationType, "browser", + "The first received packet has browser type"); + is(sentPacket.type, "listTabs", + "The first sent packet is for list of tabs"); +} + +// Listen to the transport object that is associated with the +// default Toolbox debugger client +var sent1 = []; +var received1 = []; + +function send1(eventId, packet) { + sent1.push(packet); +} + +function onPacket1(eventId, packet) { + received1.push(packet); +} + +function onToolboxCreated(eventId, toolbox) { + toolbox.target.makeRemote(); + let client = toolbox.target.client; + let transport = client._transport; + + transport.on("send", send1); + transport.on("packet", onPacket1); + + client.addOneTimeListener("closed", event => { + transport.off("send", send1); + transport.off("packet", onPacket1); + }); +} + +// Listen to all debugger client object protocols. +var sent2 = []; +var received2 = []; + +function send2(eventId, packet) { + sent2.push(packet); +} + +function onPacket2(eventId, packet) { + received2.push(packet); +} + +function onDebuggerClientConnect(client) { + let transport = client._transport; + + transport.on("send", send2); + transport.on("packet", onPacket2); + + client.addOneTimeListener("closed", event => { + transport.off("send", send2); + transport.off("packet", onPacket2); + }); +} 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 000000000..5a9a6d9b0 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_view_source_01.js @@ -0,0 +1,46 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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}doc_viewsource.html`; +var JS_URL = `${URL_ROOT}code_math.js`; + +// Force the old debugger UI since it's directly used (see Bug 1301705) +Services.prefs.setBoolPref("devtools.debugger.new-debugger-frontend", false); +registerCleanupFunction(function* () { + Services.prefs.clearUserPref("devtools.debugger.new-debugger-frontend"); +}); + +function* viewSource() { + let toolbox = yield openNewTabAndToolbox(URL); + + yield toolbox.viewSourceInDebugger(JS_URL, 2); + + let debuggerPanel = toolbox.getPanel("jsdebugger"); + ok(debuggerPanel, "The debugger panel was opened."); + is(toolbox.currentToolId, "jsdebugger", "The debugger panel was selected."); + + let { DebuggerView } = debuggerPanel.panelWin; + let Sources = DebuggerView.Sources; + + is(Sources.selectedValue, getSourceActor(Sources, JS_URL), + "The correct source is shown in the debugger."); + is(DebuggerView.editor.getCursor().line + 1, 2, + "The correct line is highlighted in the debugger's source editor."); + + yield closeToolboxAndTab(toolbox); + finish(); +} + +function test() { + Task.spawn(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 000000000..c18e885cf --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_view_source_02.js @@ -0,0 +1,54 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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}doc_viewsource.html`; +var JS_URL = `${URL_ROOT}code_math.js`; + +// Force the old debugger UI since it's directly used (see Bug 1301705) +Services.prefs.setBoolPref("devtools.debugger.new-debugger-frontend", false); +registerCleanupFunction(function* () { + Services.prefs.clearUserPref("devtools.debugger.new-debugger-frontend"); +}); + +function* viewSource() { + let toolbox = yield openNewTabAndToolbox(URL); + let { panelWin: debuggerWin } = yield toolbox.selectTool("jsdebugger"); + let debuggerEvents = debuggerWin.EVENTS; + let { DebuggerView } = debuggerWin; + let Sources = DebuggerView.Sources; + + yield debuggerWin.once(debuggerEvents.SOURCE_SHOWN); + ok("A source was shown in the debugger."); + + is(Sources.selectedValue, getSourceActor(Sources, JS_URL), + "The correct source is initially shown in the debugger."); + is(DebuggerView.editor.getCursor().line, 0, + "The correct line is initially highlighted in the debugger's source editor."); + + yield toolbox.viewSourceInDebugger(JS_URL, 2); + + let debuggerPanel = toolbox.getPanel("jsdebugger"); + ok(debuggerPanel, "The debugger panel was opened."); + is(toolbox.currentToolId, "jsdebugger", "The debugger panel was selected."); + + is(Sources.selectedValue, getSourceActor(Sources, JS_URL), + "The correct source is shown in the debugger."); + is(DebuggerView.editor.getCursor().line + 1, 2, + "The correct line is highlighted in the debugger's source editor."); + + yield closeToolboxAndTab(toolbox); + finish(); +} + +function test() { + Task.spawn(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 000000000..2d2cda76f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_view_source_03.js @@ -0,0 +1,40 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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}doc_viewsource.html`; +var CSS_URL = `${URL_ROOT}doc_theme.css`; + +function* viewSource() { + let toolbox = yield openNewTabAndToolbox(URL); + + let fileFound = yield toolbox.viewSourceInStyleEditor(CSS_URL, 2); + ok(fileFound, "viewSourceInStyleEditor should resolve to true if source found."); + + let stylePanel = toolbox.getPanel("styleeditor"); + ok(stylePanel, "The style editor panel was opened."); + is(toolbox.currentToolId, "styleeditor", "The style editor panel was selected."); + + let { 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."); + + yield closeToolboxAndTab(toolbox); + finish(); +} + +function test() { + Task.spawn(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_04.js b/devtools/client/framework/test/browser_toolbox_view_source_04.js new file mode 100644 index 000000000..47d86fc11 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_view_source_04.js @@ -0,0 +1,39 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that Toolbox#viewSourceInScratchpad works. + */ + +var URL = `${URL_ROOT}doc_viewsource.html`; + +function* viewSource() { + let toolbox = yield openNewTabAndToolbox(URL); + let win = yield openScratchpadWindow(); + let { Scratchpad: scratchpad } = win; + + // Brahm's Cello Sonata No.1, Op.38 now in the scratchpad + scratchpad.setText("E G B C B\nA B A G A B\nG E"); + let scratchpadURL = scratchpad.uniqueName; + + // Now select another tool for focus + yield toolbox.selectTool("webconsole"); + + yield toolbox.viewSourceInScratchpad(scratchpadURL, 2); + + is(scratchpad.editor.getCursor().line, 2, + "The correct line is highlighted in scratchpad's editor."); + + win.close(); + yield closeToolboxAndTab(toolbox); + finish(); +} + +function test() { + Task.spawn(viewSource).then(finish, (aError) => { + ok(false, "Got an error: " + aError.message + "\n" + aError.stack); + finish(); + }); +} 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 000000000..9f3339728 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_reload_target.js @@ -0,0 +1,100 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +requestLongerTimeout(10); + +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>"; + +var {Toolbox} = require("devtools/client/framework/toolbox"); + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties"); + +var target, toolbox, description, reloadsSent, toolIDs; + +function test() { + addTab(TEST_URL).then(() => { + target = TargetFactory.forTab(gBrowser.selectedTab); + + target.makeRemote().then(() => { + toolIDs = gDevTools.getToolDefinitionArray() + .filter(def => def.isTargetSupported(target)) + .map(def => def.id); + gDevTools.showToolbox(target, toolIDs[0], Toolbox.HostType.BOTTOM) + .then(startReloadTest); + }); + }); +} + +function startReloadTest(aToolbox) { + getFrameScript(); // causes frame-script-utils to be loaded into the child. + toolbox = aToolbox; + + reloadsSent = 0; + let reloads = 0; + let reloadCounter = (msg) => { + reloads++; + info("Detected reload #" + reloads); + is(reloads, reloadsSent, "Reloaded from devtools window once and only for " + description + ""); + }; + gBrowser.selectedBrowser.messageManager.addMessageListener("devtools:test:load", reloadCounter); + + testAllTheTools("docked", () => { + let origHostType = toolbox.hostType; + toolbox.switchHost(Toolbox.HostType.WINDOW).then(() => { + toolbox.win.focus(); + testAllTheTools("undocked", () => { + toolbox.switchHost(origHostType).then(() => { + gBrowser.selectedBrowser.messageManager.removeMessageListener("devtools:test:load", reloadCounter); + // If we finish too early, the inspector breaks promises: + toolbox.getPanel("inspector").once("new-root", finishUp); + }); + }); + }); + }, toolIDs.length - 1 /* only test 1 tool in docked mode, to cut down test time */); +} + +function testAllTheTools(docked, callback, toolNum = 0) { + if (toolNum >= toolIDs.length) { + return callback(); + } + toolbox.selectTool(toolIDs[toolNum]).then(() => { + testReload("toolbox.reload.key", docked, toolIDs[toolNum], () => { + testReload("toolbox.reload2.key", docked, toolIDs[toolNum], () => { + testReload("toolbox.forceReload.key", docked, toolIDs[toolNum], () => { + testReload("toolbox.forceReload2.key", docked, toolIDs[toolNum], () => { + testAllTheTools(docked, callback, toolNum + 1); + }); + }); + }); + }); + }); +} + +function testReload(shortcut, docked, toolID, callback) { + let complete = () => { + gBrowser.selectedBrowser.messageManager.removeMessageListener("devtools:test:load", complete); + return callback(); + }; + gBrowser.selectedBrowser.messageManager.addMessageListener("devtools:test:load", complete); + + description = docked + " devtools with tool " + toolID + ", shortcut #" + shortcut; + info("Testing reload in " + description); + toolbox.win.focus(); + synthesizeKeyShortcut(L10N.getStr(shortcut), toolbox.win); + reloadsSent++; +} + +function finishUp() { + toolbox.destroy().then(() => { + gBrowser.removeCurrentTab(); + + target = toolbox = description = reloadsSent = toolIDs = null; + + finish(); + }); +} 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 000000000..dde06dfea --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_shortcuts.js @@ -0,0 +1,84 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var {Toolbox} = require("devtools/client/framework/toolbox"); + +var toolbox, toolIDs, idIndex, modifiedPrefs = []; + +function test() { + addTab("about:blank").then(function () { + toolIDs = []; + for (let [id, definition] of gDevTools._tools) { + if (definition.key) { + toolIDs.push(id); + + // Enable disabled tools + let pref = definition.visibilityswitch, prefValue; + try { + prefValue = Services.prefs.getBoolPref(pref); + } catch (e) { + continue; + } + if (!prefValue) { + modifiedPrefs.push(pref); + Services.prefs.setBoolPref(pref, true); + } + } + } + let target = TargetFactory.forTab(gBrowser.selectedTab); + idIndex = 0; + gDevTools.showToolbox(target, toolIDs[0], 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); + + let key = gDevTools._tools.get(toolIDs[aIndex]).key; + let toolModifiers = gDevTools._tools.get(toolIDs[aIndex]).modifiers; + let 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(event, 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 (let 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 000000000..558c2094f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_title_changes.js @@ -0,0 +1,108 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +requestLongerTimeout(5); + +var {Toolbox} = require("devtools/client/framework/toolbox"); + +function test() { + const URL_1 = "data:text/plain;charset=UTF-8,abcde"; + const URL_2 = "data:text/plain;charset=UTF-8,12345"; + const URL_3 = URL_ROOT + "browser_toolbox_window_title_changes_page.html"; + + const TOOL_ID_1 = "webconsole"; + const TOOL_ID_2 = "jsdebugger"; + + const NAME_1 = ""; + const NAME_2 = ""; + const NAME_3 = "Toolbox test for title update"; + + let toolbox; + + addTab(URL_1).then(function () { + let target = TargetFactory.forTab(gBrowser.selectedTab); + gDevTools.showToolbox(target, null, Toolbox.HostType.BOTTOM) + .then(function (aToolbox) { toolbox = aToolbox; }) + .then(() => toolbox.selectTool(TOOL_ID_1)) + + // undock toolbox and check title + .then(() => { + // 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 + return toolbox.switchHost(Toolbox.HostType.WINDOW) + .then(() => waitForTitleChange(toolbox)); + }) + .then(checkTitle.bind(null, NAME_1, URL_1, "toolbox undocked")) + + // switch to different tool and check title + .then(() => { + let onTitleChanged = waitForTitleChange(toolbox); + toolbox.selectTool(TOOL_ID_2); + return onTitleChanged; + }) + .then(checkTitle.bind(null, NAME_1, URL_1, "tool changed")) + + // navigate to different local url and check title + .then(function () { + let onTitleChanged = waitForTitleChange(toolbox); + gBrowser.loadURI(URL_2); + return onTitleChanged; + }) + .then(checkTitle.bind(null, NAME_2, URL_2, "url changed")) + + // navigate to a real url and check title + .then(() => { + let onTitleChanged = waitForTitleChange(toolbox); + gBrowser.loadURI(URL_3); + return onTitleChanged; + }) + .then(checkTitle.bind(null, NAME_3, URL_3, "url changed")) + + // destroy toolbox, create new one hosted in a window (with a + // different tool id), and check title + .then(function () { + // Give the tools a chance to handle the navigation event before + // destroying the toolbox. + executeSoon(function () { + toolbox.destroy() + .then(function () { + // After destroying the toolbox, a fresh target is required. + target = TargetFactory.forTab(gBrowser.selectedTab); + return gDevTools.showToolbox(target, null, Toolbox.HostType.WINDOW); + }) + .then(function (aToolbox) { toolbox = aToolbox; }) + .then(() => { + let onTitleChanged = waitForTitleChange(toolbox); + toolbox.selectTool(TOOL_ID_1); + return onTitleChanged; + }) + .then(checkTitle.bind(null, NAME_3, URL_3, + "toolbox destroyed and recreated")) + + // clean up + .then(() => toolbox.destroy()) + .then(function () { + toolbox = null; + gBrowser.removeCurrentTab(); + Services.prefs.clearUserPref("devtools.toolbox.host"); + Services.prefs.clearUserPref("devtools.toolbox.selectedTool"); + Services.prefs.clearUserPref("devtools.toolbox.sideEnabled"); + finish(); + }); + }); + }); + }); +} + +function checkTitle(name, url, context) { + let win = Services.wm.getMostRecentWindow("devtools:toolbox"); + let expectedTitle; + if (name) { + expectedTitle = `Developer Tools - ${name} - ${url}`; + } else { + expectedTitle = `Developer Tools - ${url}`; + } + is(win.document.title, expectedTitle, context); +} 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 000000000..8678469ee --- /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 000000000..1e3d66646 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_title_frame_select.js @@ -0,0 +1,94 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from shared-head.js */ + +"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("devtools/client/framework/toolbox"); +const URL = URL_ROOT + "browser_toolbox_window_title_frame_select_page.html"; +const IFRAME_URL = URL_ROOT + "browser_toolbox_window_title_changes_page.html"; + +add_task(function* () { + Services.prefs.setBoolPref("devtools.command-button-frames.enabled", true); + + yield addTab(URL); + let target = TargetFactory.forTab(gBrowser.selectedTab); + let toolbox = yield gDevTools.showToolbox(target, null, + Toolbox.HostType.BOTTOM); + + let onTitleChanged = waitForTitleChange(toolbox); + yield toolbox.selectTool("inspector"); + yield onTitleChanged; + + yield 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 + yield 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 + yield waitForTick(); + + // Open frame menu and wait till it's available on the screen. + // Also check 'open' attribute on the command button. + let btn = toolbox.doc.getElementById("command-button-frames"); + ok(!btn.getAttribute("open"), "The open attribute must not be present"); + let menu = toolbox.showFramesMenu({target: btn}); + yield once(menu, "open"); + + is(btn.getAttribute("open"), "true", "The open attribute must be set"); + + // Verify that the frame list menu is populated + let frames = menu.items; + is(frames.length, 2, "We have both frames in the list"); + + let topFrameBtn = frames.filter(b => b.label == URL)[0]; + let iframeBtn = frames.filter(b => b.label == 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 + let willNavigate = toolbox.target.once("will-navigate"); + + onTitleChanged = waitForTitleChange(toolbox); + + // Only select the iframe after we are able to select an element from the top + // level document. + let newRoot = toolbox.getPanel("inspector").once("new-root"); + info("Select the iframe"); + iframeBtn.click(); + + yield willNavigate; + yield newRoot; + yield onTitleChanged; + + 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."); + yield toolbox.destroy(); + toolbox = null; + gBrowser.removeCurrentTab(); + Services.prefs.clearUserPref("devtools.toolbox.host"); + Services.prefs.clearUserPref("devtools.toolbox.selectedTool"); + Services.prefs.clearUserPref("devtools.toolbox.sideEnabled"); + Services.prefs.clearUserPref("devtools.command-button-frames.enabled"); + finish(); +}); + +function getTitle() { + return Services.wm.getMostRecentWindow("devtools:toolbox").document.title; +} 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 000000000..1eda94a9c --- /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 000000000..d078b4bc2 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_zoom.js @@ -0,0 +1,67 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var toolbox; + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties"); + +function test() { + addTab("about:blank").then(openToolbox); +} + +function openToolbox() { + let target = TargetFactory.forTab(gBrowser.selectedTab); + + gDevTools.showToolbox(target).then((aToolbox) => { + toolbox = aToolbox; + toolbox.selectTool("styleeditor").then(testZoom); + }); +} + +function testZoom() { + info("testing zoom keys"); + + testZoomLevel("In", 2, 1.2); + testZoomLevel("Out", 3, 0.9); + testZoomLevel("Reset", 1, 1); + + tidyUp(); +} + +function testZoomLevel(type, times, expected) { + sendZoomKey("toolbox.zoom" + type + ".key", times); + + let zoom = getCurrentZoom(toolbox); + is(zoom.toFixed(2), expected, "zoom level correct after zoom " + type); + + let savedZoom = parseFloat(Services.prefs.getCharPref( + "devtools.toolbox.zoomValue")); + is(savedZoom.toFixed(2), expected, + "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() { + let windowUtils = toolbox.win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + return windowUtils.fullZoom; +} + +function tidyUp() { + toolbox.destroy().then(function () { + gBrowser.removeCurrentTab(); + + toolbox = null; + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_two_tabs.js b/devtools/client/framework/test/browser_two_tabs.js new file mode 100644 index 000000000..08d5f2391 --- /dev/null +++ b/devtools/client/framework/test/browser_two_tabs.js @@ -0,0 +1,149 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check regression when opening two tabs + */ + +var { DebuggerServer } = require("devtools/server/main"); +var { DebuggerClient } = require("devtools/shared/client/main"); + +const TAB_URL_1 = "data:text/html;charset=utf-8,foo"; +const TAB_URL_2 = "data:text/html;charset=utf-8,bar"; + +var gClient; +var gTab1, gTab2; +var gTabActor1, gTabActor2; + +function test() { + waitForExplicitFinish(); + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + + openTabs(); +} + +function openTabs() { + // Open two tabs, select the second + addTab(TAB_URL_1).then(tab1 => { + gTab1 = tab1; + addTab(TAB_URL_2).then(tab2 => { + gTab2 = tab2; + + connect(); + }); + }); +} + +function connect() { + // Connect to debugger server to fetch the two tab actors + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect() + .then(() => gClient.listTabs()) + .then(response => { + // Fetch the tab actors for each tab + gTabActor1 = response.tabs.filter(a => a.url === TAB_URL_1)[0]; + gTabActor2 = response.tabs.filter(a => a.url === TAB_URL_2)[0]; + + checkGetTab(); + }); +} + +function checkGetTab() { + gClient.getTab({tab: gTab1}) + .then(response => { + is(JSON.stringify(gTabActor1), JSON.stringify(response.tab), + "getTab returns the same tab grip for first tab"); + }) + .then(() => { + let filter = {}; + // Filter either by tabId or outerWindowID, + // if we are running tests OOP or not. + if (gTab1.linkedBrowser.frameLoader.tabParent) { + filter.tabId = gTab1.linkedBrowser.frameLoader.tabParent.tabId; + } else { + let windowUtils = gTab1.linkedBrowser.contentWindow + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + filter.outerWindowID = windowUtils.outerWindowID; + } + return gClient.getTab(filter); + }) + .then(response => { + is(JSON.stringify(gTabActor1), JSON.stringify(response.tab), + "getTab returns the same tab grip when filtering by tabId/outerWindowID"); + }) + .then(() => gClient.getTab({tab: gTab2})) + .then(response => { + is(JSON.stringify(gTabActor2), JSON.stringify(response.tab), + "getTab returns the same tab grip for second tab"); + }) + .then(checkGetTabFailures); +} + +function checkGetTabFailures() { + gClient.getTab({ tabId: -999 }) + .then( + response => ok(false, "getTab unexpectedly succeed with a wrong tabId"), + response => { + is(response.error, "noTab"); + is(response.message, "Unable to find tab with tabId '-999'"); + } + ) + .then(() => gClient.getTab({ outerWindowID: -999 })) + .then( + response => ok(false, "getTab unexpectedly succeed with a wrong outerWindowID"), + response => { + is(response.error, "noTab"); + is(response.message, "Unable to find tab with outerWindowID '-999'"); + } + ) + .then(checkSelectedTabActor); + +} + +function checkSelectedTabActor() { + // Send a naive request to the second tab actor + // to check if it works + gClient.request({ to: gTabActor2.consoleActor, type: "startListeners", listeners: [] }, aResponse => { + ok("startedListeners" in aResponse, "Actor from the selected tab should respond to the request."); + + closeSecondTab(); + }); +} + +function closeSecondTab() { + // Close the second tab, currently selected + let container = gBrowser.tabContainer; + container.addEventListener("TabClose", function onTabClose() { + container.removeEventListener("TabClose", onTabClose); + + checkFirstTabActor(); + }); + gBrowser.removeTab(gTab2); +} + +function checkFirstTabActor() { + // then send a request to the first tab actor + // to check if it still works + gClient.request({ to: gTabActor1.consoleActor, type: "startListeners", listeners: [] }, aResponse => { + ok("startedListeners" in aResponse, "Actor from the first tab should still respond."); + + cleanup(); + }); +} + +function cleanup() { + let container = gBrowser.tabContainer; + container.addEventListener("TabClose", function onTabClose() { + container.removeEventListener("TabClose", onTabClose); + + gClient.close().then(finish); + }); + gBrowser.removeTab(gTab1); +} 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 000000000..e3dacdaaa --- /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 000000000..c43848a60 --- /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 000000000..8d2251125 --- /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_math.js b/devtools/client/framework/test/code_math.js new file mode 100644 index 000000000..9fe2a3541 --- /dev/null +++ b/devtools/client/framework/test/code_math.js @@ -0,0 +1,9 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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_ugly.js b/devtools/client/framework/test/code_ugly.js new file mode 100644 index 000000000..ccf8d5488 --- /dev/null +++ b/devtools/client/framework/test/code_ugly.js @@ -0,0 +1,3 @@ +function foo() { var a=1; var b=2; bar(a, b); } +function bar(c, d) { return c - d; } +foo(); 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 000000000..28398f776 --- /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_theme.css b/devtools/client/framework/test/doc_theme.css new file mode 100644 index 000000000..5ed6e866a --- /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 000000000..7094eb87e --- /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 000000000..22433b237 --- /dev/null +++ b/devtools/client/framework/test/head.js @@ -0,0 +1,148 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from shared-head.js */ + +// shared-head.js handles imports, constants, and utility functions +Services.scriptloader.loadSubScript("chrome://mochitests/content/browser/devtools/client/framework/test/shared-head.js", this); + +function toggleAllTools(state) { + for (let [, tool] of gDevTools._tools) { + if (!tool.visibilityswitch) { + continue; + } + if (state) { + Services.prefs.setBoolPref(tool.visibilityswitch, true); + } else { + Services.prefs.clearUserPref(tool.visibilityswitch); + } + } +} + +function getChromeActors(callback) +{ + let { DebuggerServer } = require("devtools/server/main"); + let { DebuggerClient } = require("devtools/shared/client/main"); + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + DebuggerServer.allowChromeProcess = true; + + let client = new DebuggerClient(DebuggerServer.connectPipe()); + client.connect() + .then(() => client.getProcess()) + .then(response => { + callback(client, response.form); + }); + + SimpleTest.registerCleanupFunction(() => { + DebuggerServer.destroy(); + }); +} + +function getSourceActor(aSources, aURL) { + let item = aSources.getItemForAttachment(a => a.source.url === aURL); + return item && item.value; +} + +/** + * Open a Scratchpad window. + * + * @return nsIDOMWindow + * The new window object that holds Scratchpad. + */ +function* openScratchpadWindow() { + let { promise: p, resolve } = defer(); + let win = ScratchpadManager.openScratchpad(); + + yield once(win, "load"); + + win.Scratchpad.addObserver({ + onReady: function () { + win.Scratchpad.removeObserver(this); + resolve(win); + } + }); + return p; +} + +/** + * Wait for a content -> chrome message on the message manager (the window + * messagemanager is used). + * @param {String} name The message name + * @return {Promise} A promise that resolves to the response data when the + * message has been received + */ +function waitForContentMessage(name) { + info("Expecting message " + name + " from content"); + + let mm = gBrowser.selectedBrowser.messageManager; + + let def = defer(); + mm.addMessageListener(name, function onMessage(msg) { + mm.removeMessageListener(name, onMessage); + def.resolve(msg.data); + }); + return def.promise; +} + +/** + * Send an async message to the frame script (chrome -> content) and wait for a + * response message with the same name (content -> chrome). + * @param {String} name The message name. Should be one of the messages defined + * in doc_frame_script.js + * @param {Object} data Optional data to send along + * @param {Object} objects Optional CPOW objects to send along + * @param {Boolean} expectResponse If set to false, don't wait for a response + * with the same name from the content script. Defaults to true. + * @return {Promise} Resolves to the response data if a response is expected, + * immediately resolves otherwise + */ +function executeInContent(name, data = {}, objects = {}, expectResponse = true) { + info("Sending message " + name + " to content"); + let mm = gBrowser.selectedBrowser.messageManager; + + mm.sendAsyncMessage(name, data, objects); + if (expectResponse) { + return waitForContentMessage(name); + } else { + return promise.resolve(); + } +} + +/** + * Synthesize a keypress from a <key> element, taking into account + * any modifiers. + * @param {Element} el the <key> element to synthesize + */ +function synthesizeKeyElement(el) { + let key = el.getAttribute("key") || el.getAttribute("keycode"); + let 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); + + let 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"); + } +} 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 000000000..5e2feef8f --- /dev/null +++ b/devtools/client/framework/test/helper_disable_cache.js @@ -0,0 +1,128 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// 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 + }]; + +function* initTab(tabX, startToolbox) { + tabX.tab = yield addTab(TEST_URI); + tabX.target = TargetFactory.forTab(tabX.tab); + + if (startToolbox) { + tabX.toolbox = yield gDevTools.showToolbox(tabX.target, "options"); + } +} + +function* checkCacheStateForAllTabs(states) { + for (let i = 0; i < tabs.length; i++) { + let tab = tabs[i]; + yield checkCacheEnabled(tab, states[i]); + } +} + +function* checkCacheEnabled(tabX, expected) { + gBrowser.selectedTab = tabX.tab; + + yield reloadTab(tabX); + + let oldGuid = yield ContentTask.spawn(gBrowser.selectedBrowser, {}, function () { + let doc = content.document; + let h1 = doc.querySelector("h1"); + return h1.textContent; + }); + + yield reloadTab(tabX); + + let guid = yield ContentTask.spawn(gBrowser.selectedBrowser, {}, function () { + let doc = content.document; + let 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"); + } +} + +function* setDisableCacheCheckboxChecked(tabX, state) { + gBrowser.selectedTab = tabX.tab; + + let panel = tabX.toolbox.getCurrentPanel(); + let cbx = panel.panelDoc.getElementById("devtools-disable-cache"); + + if (cbx.checked !== state) { + info("Setting disable cache checkbox to " + state + " for " + tabX.title); + cbx.click(); + + // We need to wait for all checkboxes to be updated and the docshells to + // apply the new cache settings. + yield waitForTick(); + } +} + +function reloadTab(tabX) { + let def = defer(); + let browser = gBrowser.selectedBrowser; + + BrowserTestUtils.browserLoaded(browser).then(function () { + info("Reloaded tab " + tabX.title); + def.resolve(); + }); + + info("Reloading tab " + tabX.title); + let mm = getFrameScript(); + mm.sendAsyncMessage("devtools:test:reload"); + + return def.promise; +} + +function* destroyTab(tabX) { + let toolbox = gDevTools.getToolbox(tabX.target); + + let onceDestroyed = promise.resolve(); + 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"); + yield onceDestroyed; +} + +function* finishUp() { + for (let tab of tabs) { + yield destroyTab(tab); + } + + tabs = null; +} diff --git a/devtools/client/framework/test/serviceworker.js b/devtools/client/framework/test/serviceworker.js new file mode 100644 index 000000000..ed3c1ec32 --- /dev/null +++ b/devtools/client/framework/test/serviceworker.js @@ -0,0 +1,6 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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/shared-head.js b/devtools/client/framework/test/shared-head.js new file mode 100644 index 000000000..a89c6d752 --- /dev/null +++ b/devtools/client/framework/test/shared-head.js @@ -0,0 +1,596 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +"use strict"; + +// This shared-head.js file is used for multiple mochitest test directories in +// devtools. +// It contains various common helper functions. + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr, Constructor: CC} + = Components; + +function scopedCuImport(path) { + const scope = {}; + Cu.import(path, scope); + return scope; +} + +const {console} = scopedCuImport("resource://gre/modules/Console.jsm"); +const {ScratchpadManager} = scopedCuImport("resource://devtools/client/scratchpad/scratchpad-manager.jsm"); +const {loader, require} = scopedCuImport("resource://devtools/shared/Loader.jsm"); + +const {gDevTools} = require("devtools/client/framework/devtools"); +const {TargetFactory} = require("devtools/client/framework/target"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const flags = require("devtools/shared/flags"); +let promise = require("promise"); +let defer = require("devtools/shared/defer"); +const Services = require("Services"); +const {Task} = require("devtools/shared/task"); +const {KeyShortcuts} = require("devtools/client/shared/key-shortcuts"); + +const TEST_DIR = gTestPath.substr(0, gTestPath.lastIndexOf("/")); +const CHROME_URL_ROOT = TEST_DIR + "/"; +const URL_ROOT = CHROME_URL_ROOT.replace("chrome://mochitests/content/", + "http://example.com/"); +const URL_ROOT_SSL = CHROME_URL_ROOT.replace("chrome://mochitests/content/", + "https://example.com/"); + +// All test are asynchronous +waitForExplicitFinish(); + +var EXPECTED_DTU_ASSERT_FAILURE_COUNT = 0; + +registerCleanupFunction(function () { + if (DevToolsUtils.assertionFailureCount !== + EXPECTED_DTU_ASSERT_FAILURE_COUNT) { + ok(false, + "Should have had the expected number of DevToolsUtils.assert() failures." + + " Expected " + EXPECTED_DTU_ASSERT_FAILURE_COUNT + + ", got " + DevToolsUtils.assertionFailureCount); + } +}); + +// Uncomment this pref to dump all devtools emitted events to the console. +// Services.prefs.setBoolPref("devtools.dump.emit", true); + +/** + * Watch console messages for failed propType definitions in React components. + */ +const ConsoleObserver = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), + + observe: function (subject, topic, data) { + let message = subject.wrappedJSObject.arguments[0]; + + if (/Failed propType/.test(message)) { + ok(false, message); + } + } +}; + +Services.obs.addObserver(ConsoleObserver, "console-api-log-event", false); +registerCleanupFunction(() => { + Services.obs.removeObserver(ConsoleObserver, "console-api-log-event"); +}); + +var waitForTime = DevToolsUtils.waitForTime; + +function getFrameScript() { + let mm = gBrowser.selectedBrowser.messageManager; + let frameURL = "chrome://devtools/content/shared/frame-script-utils.js"; + mm.loadFrameScript(frameURL, false); + SimpleTest.registerCleanupFunction(() => { + mm = null; + }); + return mm; +} + +flags.testing = true; +registerCleanupFunction(() => { + flags.testing = false; + Services.prefs.clearUserPref("devtools.dump.emit"); + Services.prefs.clearUserPref("devtools.toolbox.host"); + Services.prefs.clearUserPref("devtools.toolbox.previousHost"); + Services.prefs.clearUserPref("devtools.toolbox.splitconsoleEnabled"); +}); + +registerCleanupFunction(function* cleanup() { + while (gBrowser.tabs.length > 1) { + yield closeTabAndToolbox(gBrowser.selectedTab); + } +}); + +/** + * Add a new test tab in the browser and load the given url. + * @param {String} url The url to be loaded in the new tab + * @param {Object} options Object with various optional fields: + * - {Boolean} background If true, open the tab in background + * - {ChromeWindow} window Firefox top level window we should use to open the tab + * @return a promise that resolves to the tab object when the url is loaded + */ +var addTab = Task.async(function* (url, options = { background: false, window: window }) { + info("Adding a new tab with URL: " + url); + + let { background } = options; + let { gBrowser } = options.window ? options.window : window; + + let tab = gBrowser.addTab(url); + if (!background) { + gBrowser.selectedTab = tab; + } + yield BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + info("Tab added and finished loading"); + + return tab; +}); + +/** + * Remove the given tab. + * @param {Object} tab The tab to be removed. + * @return Promise<undefined> resolved when the tab is successfully removed. + */ +var removeTab = Task.async(function* (tab) { + info("Removing tab."); + + let { gBrowser } = tab.ownerDocument.defaultView; + let onClose = once(gBrowser.tabContainer, "TabClose"); + gBrowser.removeTab(tab); + yield onClose; + + info("Tab removed and finished closing"); +}); + +/** + * Refresh the given tab. + * @param {Object} tab The tab to be refreshed. + * @return Promise<undefined> resolved when the tab is successfully refreshed. + */ +var refreshTab = Task.async(function*(tab) { + info("Refreshing tab."); + const finished = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + gBrowser.reloadTab(gBrowser.selectedTab); + yield finished; + info("Tab finished refreshing."); +}); + +/** + * Simulate a key event from a <key> element. + * @param {DOMNode} key + */ +function synthesizeKeyFromKeyTag(key) { + is(key && key.tagName, "key", "Successfully retrieved the <key> node"); + + let modifiersAttr = key.getAttribute("modifiers"); + + let name = null; + + if (key.getAttribute("keycode")) { + name = key.getAttribute("keycode"); + } else if (key.getAttribute("key")) { + name = key.getAttribute("key"); + } + + isnot(name, null, "Successfully retrieved keycode/key"); + + let modifiers = { + shiftKey: !!modifiersAttr.match("shift"), + ctrlKey: !!modifiersAttr.match("control"), + altKey: !!modifiersAttr.match("alt"), + metaKey: !!modifiersAttr.match("meta"), + accelKey: !!modifiersAttr.match("accel") + }; + + info("Synthesizing key " + name + " " + JSON.stringify(modifiers)); + EventUtils.synthesizeKey(name, modifiers); +} + +/** + * Simulate a key event from an electron key shortcut string: + * https://github.com/electron/electron/blob/master/docs/api/accelerator.md + * + * @param {String} key + * @param {DOMWindow} target + * Optional window where to fire the key event + */ +function synthesizeKeyShortcut(key, target) { + // parseElectronKey requires any window, just to access `KeyboardEvent` + let window = Services.appShell.hiddenDOMWindow; + let shortcut = KeyShortcuts.parseElectronKey(window, key); + let keyEvent = { + altKey: shortcut.alt, + ctrlKey: shortcut.ctrl, + metaKey: shortcut.meta, + shiftKey: shortcut.shift + }; + if (shortcut.keyCode) { + keyEvent.keyCode = shortcut.keyCode; + } + + info("Synthesizing key shortcut: " + key); + EventUtils.synthesizeKey(shortcut.key || "", keyEvent, target); +} + +/** + * Wait for eventName on target to be delivered a number of times. + * + * @param {Object} target + * An observable object that either supports on/off or + * addEventListener/removeEventListener + * @param {String} eventName + * @param {Number} numTimes + * Number of deliveries to wait for. + * @param {Boolean} useCapture + * Optional, for addEventListener/removeEventListener + * @return A promise that resolves when the event has been handled + */ +function waitForNEvents(target, eventName, numTimes, useCapture = false) { + info("Waiting for event: '" + eventName + "' on " + target + "."); + + let deferred = defer(); + let count = 0; + + for (let [add, remove] of [ + ["addEventListener", "removeEventListener"], + ["addListener", "removeListener"], + ["on", "off"] + ]) { + if ((add in target) && (remove in target)) { + target[add](eventName, function onEvent(...aArgs) { + info("Got event: '" + eventName + "' on " + target + "."); + if (++count == numTimes) { + target[remove](eventName, onEvent, useCapture); + deferred.resolve.apply(deferred, aArgs); + } + }, useCapture); + break; + } + } + + return deferred.promise; +} + +/** + * Wait for eventName on target. + * + * @param {Object} target + * An observable object that either supports on/off or + * addEventListener/removeEventListener + * @param {String} eventName + * @param {Boolean} useCapture + * Optional, for addEventListener/removeEventListener + * @return A promise that resolves when the event has been handled + */ +function once(target, eventName, useCapture = false) { + return waitForNEvents(target, eventName, 1, useCapture); +} + +/** + * Some tests may need to import one or more of the test helper scripts. + * A test helper script is simply a js file that contains common test code that + * is either not common-enough to be in head.js, or that is located in a + * separate directory. + * The script will be loaded synchronously and in the test's scope. + * @param {String} filePath The file path, relative to the current directory. + * Examples: + * - "helper_attributes_test_runner.js" + * - "../../../commandline/test/helpers.js" + */ +function loadHelperScript(filePath) { + let testDir = gTestPath.substr(0, gTestPath.lastIndexOf("/")); + Services.scriptloader.loadSubScript(testDir + "/" + filePath, this); +} + +/** + * Wait for a tick. + * @return {Promise} + */ +function waitForTick() { + let deferred = defer(); + executeSoon(deferred.resolve); + return deferred.promise; +} + +/** + * This shouldn't be used in the tests, but is useful when writing new tests or + * debugging existing tests in order to introduce delays in the test steps + * + * @param {Number} ms + * The time to wait + * @return A promise that resolves when the time is passed + */ +function wait(ms) { + return new promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Open the toolbox in a given tab. + * @param {XULNode} tab The tab the toolbox should be opened in. + * @param {String} toolId Optional. The ID of the tool to be selected. + * @param {String} hostType Optional. The type of toolbox host to be used. + * @return {Promise} Resolves with the toolbox, when it has been opened. + */ +var openToolboxForTab = Task.async(function* (tab, toolId, hostType) { + info("Opening the toolbox"); + + let toolbox; + let target = TargetFactory.forTab(tab); + yield target.makeRemote(); + + // Check if the toolbox is already loaded. + toolbox = gDevTools.getToolbox(target); + if (toolbox) { + if (!toolId || (toolId && toolbox.getPanel(toolId))) { + info("Toolbox is already opened"); + return toolbox; + } + } + + // If not, load it now. + toolbox = yield gDevTools.showToolbox(target, toolId, hostType); + + // Make sure that the toolbox frame is focused. + yield new Promise(resolve => waitForFocus(resolve, toolbox.win)); + + info("Toolbox opened and focused"); + + return toolbox; +}); + +/** + * Add a new tab and open the toolbox in it. + * @param {String} url The URL for the tab to be opened. + * @param {String} toolId Optional. The ID of the tool to be selected. + * @param {String} hostType Optional. The type of toolbox host to be used. + * @return {Promise} Resolves when the tab has been added, loaded and the + * toolbox has been opened. Resolves to the toolbox. + */ +var openNewTabAndToolbox = Task.async(function* (url, toolId, hostType) { + let tab = yield addTab(url); + return openToolboxForTab(tab, toolId, hostType); +}); + +/** + * Close a tab and if necessary, the toolbox that belongs to it + * @param {Tab} tab The tab to close. + * @return {Promise} Resolves when the toolbox and tab have been destroyed and + * closed. + */ +var closeTabAndToolbox = Task.async(function* (tab = gBrowser.selectedTab) { + let target = TargetFactory.forTab(gBrowser.selectedTab); + if (target) { + yield gDevTools.closeToolbox(target); + } + + yield removeTab(gBrowser.selectedTab); +}); + +/** + * Close a toolbox and the current tab. + * @param {Toolbox} toolbox The toolbox to close. + * @return {Promise} Resolves when the toolbox and tab have been destroyed and + * closed. + */ +var closeToolboxAndTab = Task.async(function* (toolbox) { + yield toolbox.destroy(); + yield removeTab(gBrowser.selectedTab); +}); + +/** + * Waits until a predicate returns true. + * + * @param function predicate + * Invoked once in a while until it returns true. + * @param number interval [optional] + * How often the predicate is invoked, in milliseconds. + */ +function waitUntil(predicate, interval = 10) { + if (predicate()) { + return Promise.resolve(true); + } + return new Promise(resolve => { + setTimeout(function () { + waitUntil(predicate, interval).then(() => resolve(true)); + }, interval); + }); +} + +/** + * Takes a string `script` and evaluates it directly in the content + * in potentially a different process. + */ +let MM_INC_ID = 0; +function evalInDebuggee(mm, script) { + return new Promise(function (resolve, reject) { + let id = MM_INC_ID++; + mm.sendAsyncMessage("devtools:test:eval", { script, id }); + mm.addMessageListener("devtools:test:eval:response", handler); + + function handler({ data }) { + if (id !== data.id) { + return; + } + + info(`Successfully evaled in debuggee: ${script}`); + mm.removeMessageListener("devtools:test:eval:response", handler); + resolve(data.value); + } + }); +} + +/** + * Wait for a context menu popup to open. + * + * @param nsIDOMElement popup + * The XUL popup you expect to open. + * @param nsIDOMElement button + * The button/element that receives the contextmenu event. This is + * expected to open the popup. + * @param function onShown + * Function to invoke on popupshown event. + * @param function onHidden + * Function to invoke on popuphidden event. + * @return object + * A Promise object that is resolved after the popuphidden event + * callback is invoked. + */ +function waitForContextMenu(popup, button, onShown, onHidden) { + let deferred = defer(); + + function onPopupShown() { + info("onPopupShown"); + popup.removeEventListener("popupshown", onPopupShown); + + onShown && onShown(); + + // Use executeSoon() to get out of the popupshown event. + popup.addEventListener("popuphidden", onPopupHidden); + executeSoon(() => popup.hidePopup()); + } + function onPopupHidden() { + info("onPopupHidden"); + popup.removeEventListener("popuphidden", onPopupHidden); + + onHidden && onHidden(); + + deferred.resolve(popup); + } + + popup.addEventListener("popupshown", onPopupShown); + + info("wait for the context menu to open"); + button.scrollIntoView(); + let eventDetails = {type: "contextmenu", button: 2}; + EventUtils.synthesizeMouse(button, 5, 2, eventDetails, + button.ownerDocument.defaultView); + return deferred.promise; +} + +/** + * Promise wrapper around SimpleTest.waitForClipboard + */ +function waitForClipboardPromise(setup, expected) { + return new Promise((resolve, reject) => { + SimpleTest.waitForClipboard(expected, setup, resolve, reject); + }); +} + +/** + * Simple helper to push a temporary preference. Wrapper on SpecialPowers + * pushPrefEnv that returns a promise resolving when the preferences have been + * updated. + * + * @param {String} preferenceName + * The name of the preference to updated + * @param {} value + * The preference value, type can vary + * @return {Promise} resolves when the preferences have been updated + */ +function pushPref(preferenceName, value) { + return new Promise(resolve => { + let options = {"set": [[preferenceName, value]]}; + SpecialPowers.pushPrefEnv(options, resolve); + }); +} + +/** + * Lookup the provided dotted path ("prop1.subprop2.myProp") in the provided object. + * + * @param {Object} obj + * Object to expand. + * @param {String} path + * Dotted path to use to expand the object. + * @return {?} anything that is found at the provided path in the object. + */ +function lookupPath(obj, path) { + let segments = path.split("."); + return segments.reduce((prev, current) => prev[current], obj); +} + +var closeToolbox = Task.async(function* () { + let target = TargetFactory.forTab(gBrowser.selectedTab); + yield gDevTools.closeToolbox(target); +}); + +/** + * Load the Telemetry utils, then stub Telemetry.prototype.log and + * Telemetry.prototype.logKeyed in order to record everything that's logged in + * it. + * Store all recordings in Telemetry.telemetryInfo. + * @return {Telemetry} + */ +function loadTelemetryAndRecordLogs() { + info("Mock the Telemetry log function to record logged information"); + + let Telemetry = require("devtools/client/shared/telemetry"); + Telemetry.prototype.telemetryInfo = {}; + Telemetry.prototype._oldlog = Telemetry.prototype.log; + Telemetry.prototype.log = function (histogramId, value) { + if (!this.telemetryInfo) { + // Telemetry instance still in use after stopRecordingTelemetryLogs + return; + } + if (histogramId) { + if (!this.telemetryInfo[histogramId]) { + this.telemetryInfo[histogramId] = []; + } + this.telemetryInfo[histogramId].push(value); + } + }; + Telemetry.prototype._oldlogKeyed = Telemetry.prototype.logKeyed; + Telemetry.prototype.logKeyed = function (histogramId, key, value) { + this.log(`${histogramId}|${key}`, value); + }; + + return Telemetry; +} + +/** + * Stop recording the Telemetry logs and put back the utils as it was before. + * @param {Telemetry} Required Telemetry + * Telemetry object that needs to be stopped. + */ +function stopRecordingTelemetryLogs(Telemetry) { + info("Stopping Telemetry"); + Telemetry.prototype.log = Telemetry.prototype._oldlog; + Telemetry.prototype.logKeyed = Telemetry.prototype._oldlogKeyed; + delete Telemetry.prototype._oldlog; + delete Telemetry.prototype._oldlogKeyed; + delete Telemetry.prototype.telemetryInfo; +} + +/** + * Clean the logical clipboard content. This method only clears the OS clipboard on + * Windows (see Bug 666254). + */ +function emptyClipboard() { + let clipboard = Cc["@mozilla.org/widget/clipboard;1"] + .getService(SpecialPowers.Ci.nsIClipboard); + clipboard.emptyClipboard(clipboard.kGlobalClipboard); +} + +/** + * Check if the current operating system is Windows. + */ +function isWindows() { + return Services.appinfo.OS === "WINNT"; +} + +/** + * Wait for a given toolbox to get its title updated. + */ +function waitForTitleChange(toolbox) { + let deferred = defer(); + toolbox.win.parent.addEventListener("message", function onmessage(event) { + if (event.data.name == "set-host-title") { + toolbox.win.parent.removeEventListener("message", onmessage); + deferred.resolve(); + } + }); + return deferred.promise; +} diff --git a/devtools/client/framework/test/shared-redux-head.js b/devtools/client/framework/test/shared-redux-head.js new file mode 100644 index 000000000..c7c939152 --- /dev/null +++ b/devtools/client/framework/test/shared-redux-head.js @@ -0,0 +1,85 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ +/* import-globals-from ./shared-head.js */ +// Currently this file expects "defer" to be imported into scope. + +// Common utility functions for working with Redux stores. The file is meant +// to be safe to load in both mochitest and xpcshell environments. + +/** + * A logging function that can be used from xpcshell and browser mochitest + * environments. + */ +function commonLog(message) { + let log; + if (Services && Services.appinfo && Services.appinfo.name && + Services.appinfo.name == "Firefox") { + log = info; + } else { + log = do_print; + } + log(message); +} + +/** + * Wait until the store has reached a state that matches the predicate. + * @param Store store + * The Redux store being used. + * @param function predicate + * A function that returns true when the store has reached the expected + * state. + * @return Promise + * Resolved once the store reaches the expected state. + */ +function waitUntilState(store, predicate) { + let deferred = defer(); + let unsubscribe = store.subscribe(check); + + commonLog(`Waiting for state predicate "${predicate}"`); + function check() { + if (predicate(store.getState())) { + commonLog(`Found state predicate "${predicate}"`); + unsubscribe(); + deferred.resolve(); + } + } + + // Fire the check immediately in case the action has already occurred + check(); + + return deferred.promise; +} + +/** + * Wait until a particular action has been emitted by the store. + * @param Store store + * The Redux store being used. + * @param string actionType + * The expected action to wait for. + * @return Promise + * Resolved once the expected action is emitted by the store. + */ +function waitUntilAction(store, actionType) { + let deferred = defer(); + let unsubscribe = store.subscribe(check); + let history = store.history; + let index = history.length; + + commonLog(`Waiting for action "${actionType}"`); + function check() { + let action = history[index++]; + if (action && action.type === actionType) { + commonLog(`Found action "${actionType}"`); + unsubscribe(); + deferred.resolve(store.getState()); + } + } + + return deferred.promise; +} diff --git a/devtools/client/framework/toolbox-highlighter-utils.js b/devtools/client/framework/toolbox-highlighter-utils.js new file mode 100644 index 000000000..e7f343857 --- /dev/null +++ b/devtools/client/framework/toolbox-highlighter-utils.js @@ -0,0 +1,324 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 promise = require("promise"); +const {Task} = require("devtools/shared/task"); +const flags = require("devtools/shared/flags"); + +/** + * Client-side highlighter shared module. + * To be used by toolbox panels that need to highlight DOM elements. + * + * Highlighting and selecting elements is common enough that it needs to be at + * toolbox level, accessible by any panel that needs it. + * That's why the toolbox is the one that initializes the inspector and + * highlighter. It's also why the API returned by this module needs a reference + * to the toolbox which should be set once only. + */ + +/** + * Get the highighterUtils instance for a given toolbox. + * This should be done once only by the toolbox itself and stored there so that + * panels can get it from there. That's because the API returned has a stateful + * scope that would be different for another instance returned by this function. + * + * @param {Toolbox} toolbox + * @return {Object} the highlighterUtils public API + */ +exports.getHighlighterUtils = function (toolbox) { + if (!toolbox || !toolbox.target) { + throw new Error("Missing or invalid toolbox passed to getHighlighterUtils"); + return; + } + + // Exported API properties will go here + let exported = {}; + + // The current toolbox target + let target = toolbox.target; + + // Is the highlighter currently in pick mode + let isPicking = false; + + // Is the box model already displayed, used to prevent dispatching + // unnecessary requests, especially during toolbox shutdown + let isNodeFrontHighlighted = false; + + /** + * Release this utils, nullifying the references to the toolbox + */ + exported.release = function () { + toolbox = target = null; + }; + + /** + * Does the target have the highlighter actor. + * The devtools must be backwards compatible with at least B2G 1.3 (28), + * which doesn't have the highlighter actor. This can be removed as soon as + * the minimal supported version becomes 1.4 (29) + */ + let isRemoteHighlightable = exported.isRemoteHighlightable = function () { + return target.client.traits.highlightable; + }; + + /** + * Does the target support custom highlighters. + */ + let supportsCustomHighlighters = exported.supportsCustomHighlighters = () => { + return !!target.client.traits.customHighlighters; + }; + + /** + * Make a function that initializes the inspector before it runs. + * Since the init of the inspector is asynchronous, the return value will be + * produced by Task.async and the argument should be a generator + * @param {Function*} generator A generator function + * @return {Function} A function + */ + let isInspectorInitialized = false; + let requireInspector = generator => { + return Task.async(function* (...args) { + if (!isInspectorInitialized) { + yield toolbox.initInspector(); + isInspectorInitialized = true; + } + return yield generator.apply(null, args); + }); + }; + + /** + * Start/stop the element picker on the debuggee target. + * @param {Boolean} doFocus - Optionally focus the content area once the picker is + * activated. + * @return A promise that resolves when done + */ + let togglePicker = exported.togglePicker = function (doFocus) { + if (isPicking) { + return cancelPicker(); + } else { + return startPicker(doFocus); + } + }; + + /** + * Start the element picker on the debuggee target. + * This will request the inspector actor to start listening for mouse events + * on the target page to highlight the hovered/picked element. + * Depending on the server-side capabilities, this may fire events when nodes + * are hovered. + * @param {Boolean} doFocus - Optionally focus the content area once the picker is + * activated. + * @return A promise that resolves when the picker has started or immediately + * if it is already started + */ + let startPicker = exported.startPicker = requireInspector(function* (doFocus = false) { + if (isPicking) { + return; + } + isPicking = true; + + toolbox.pickerButtonChecked = true; + yield toolbox.selectTool("inspector"); + toolbox.on("select", cancelPicker); + + if (isRemoteHighlightable()) { + toolbox.walker.on("picker-node-hovered", onPickerNodeHovered); + toolbox.walker.on("picker-node-picked", onPickerNodePicked); + toolbox.walker.on("picker-node-previewed", onPickerNodePreviewed); + toolbox.walker.on("picker-node-canceled", onPickerNodeCanceled); + + yield toolbox.highlighter.pick(doFocus); + toolbox.emit("picker-started"); + } else { + // If the target doesn't have the highlighter actor, we can use the + // walker's pick method instead, knowing that it only responds when a node + // is picked (instead of emitting events) + toolbox.emit("picker-started"); + let node = yield toolbox.walker.pick(); + onPickerNodePicked({node: node}); + } + }); + + /** + * Stop the element picker. Note that the picker is automatically stopped when + * an element is picked + * @return A promise that resolves when the picker has stopped or immediately + * if it is already stopped + */ + let stopPicker = exported.stopPicker = requireInspector(function* () { + if (!isPicking) { + return; + } + isPicking = false; + + toolbox.pickerButtonChecked = false; + + if (isRemoteHighlightable()) { + yield toolbox.highlighter.cancelPick(); + toolbox.walker.off("picker-node-hovered", onPickerNodeHovered); + toolbox.walker.off("picker-node-picked", onPickerNodePicked); + toolbox.walker.off("picker-node-previewed", onPickerNodePreviewed); + toolbox.walker.off("picker-node-canceled", onPickerNodeCanceled); + } else { + // If the target doesn't have the highlighter actor, use the walker's + // cancelPick method instead + yield toolbox.walker.cancelPick(); + } + + toolbox.off("select", cancelPicker); + toolbox.emit("picker-stopped"); + }); + + /** + * Stop the picker, but also emit an event that the picker was canceled. + */ + let cancelPicker = exported.cancelPicker = Task.async(function* () { + yield stopPicker(); + toolbox.emit("picker-canceled"); + }); + + /** + * When a node is hovered by the mouse when the highlighter is in picker mode + * @param {Object} data Information about the node being hovered + */ + function onPickerNodeHovered(data) { + toolbox.emit("picker-node-hovered", data.node); + } + + /** + * When a node has been picked while the highlighter is in picker mode + * @param {Object} data Information about the picked node + */ + function onPickerNodePicked(data) { + toolbox.selection.setNodeFront(data.node, "picker-node-picked"); + stopPicker(); + } + + /** + * When a node has been shift-clicked (previewed) while the highlighter is in + * picker mode + * @param {Object} data Information about the picked node + */ + function onPickerNodePreviewed(data) { + toolbox.selection.setNodeFront(data.node, "picker-node-previewed"); + } + + /** + * When the picker is canceled, stop the picker, and make sure the toolbox + * gets the focus. + */ + function onPickerNodeCanceled() { + cancelPicker(); + toolbox.win.focus(); + } + + /** + * Show the box model highlighter on a node in the content page. + * The node needs to be a NodeFront, as defined by the inspector actor + * @see devtools/server/actors/inspector.js + * @param {NodeFront} nodeFront The node to highlight + * @param {Object} options + * @return A promise that resolves when the node has been highlighted + */ + let highlightNodeFront = exported.highlightNodeFront = requireInspector( + function* (nodeFront, options = {}) { + if (!nodeFront) { + return; + } + + isNodeFrontHighlighted = true; + if (isRemoteHighlightable()) { + yield toolbox.highlighter.showBoxModel(nodeFront, options); + } else { + // If the target doesn't have the highlighter actor, revert to the + // walker's highlight method, which draws a simple outline + yield toolbox.walker.highlight(nodeFront); + } + + toolbox.emit("node-highlight", nodeFront, options.toSource()); + }); + + /** + * This is a convenience method in case you don't have a nodeFront but a + * valueGrip. This is often the case with VariablesView properties. + * This method will simply translate the grip into a nodeFront and call + * highlightNodeFront, so it has the same signature. + * @see highlightNodeFront + */ + let highlightDomValueGrip = exported.highlightDomValueGrip = requireInspector( + function* (valueGrip, options = {}) { + let nodeFront = yield gripToNodeFront(valueGrip); + if (nodeFront) { + yield highlightNodeFront(nodeFront, options); + } else { + throw new Error("The ValueGrip passed could not be translated to a NodeFront"); + } + }); + + /** + * Translate a debugger value grip into a node front usable by the inspector + * @param {ValueGrip} + * @return a promise that resolves to the node front when done + */ + let gripToNodeFront = exported.gripToNodeFront = requireInspector( + function* (grip) { + return yield toolbox.walker.getNodeActorFromObjectActor(grip.actor); + }); + + /** + * Hide the highlighter. + * @param {Boolean} forceHide Only really matters in test mode (when + * flags.testing is true). In test mode, hovering over several nodes + * in the markup view doesn't hide/show the highlighter to ease testing. The + * highlighter stays visible at all times, except when the mouse leaves the + * markup view, which is when this param is passed to true + * @return a promise that resolves when the highlighter is hidden + */ + let unhighlight = exported.unhighlight = Task.async( + function* (forceHide = false) { + forceHide = forceHide || !flags.testing; + + // Note that if isRemoteHighlightable is true, there's no need to hide the + // highlighter as the walker uses setTimeout to hide it after some time + if (isNodeFrontHighlighted && forceHide && toolbox.highlighter && isRemoteHighlightable()) { + isNodeFrontHighlighted = false; + yield toolbox.highlighter.hideBoxModel(); + } + + // unhighlight is called when destroying the toolbox, which means that by + // now, the toolbox reference might have been nullified already. + if (toolbox) { + toolbox.emit("node-unhighlight"); + } + }); + + /** + * If the main, box-model, highlighter isn't enough, or if multiple + * highlighters are needed in parallel, this method can be used to return a + * new instance of a highlighter actor, given a type. + * The type of the highlighter passed must be known by the server. + * The highlighter actor returned will have the show(nodeFront) and hide() + * methods and needs to be released by the consumer when not needed anymore. + * @return a promise that resolves to the highlighter + */ + let getHighlighterByType = exported.getHighlighterByType = requireInspector( + function* (typeName) { + let highlighter = null; + + if (supportsCustomHighlighters()) { + highlighter = yield toolbox.inspector.getHighlighterByType(typeName); + } + + return highlighter || promise.reject("The target doesn't support " + + `creating highlighters by types or ${typeName} is unknown`); + + }); + + // Return the public API + return exported; +}; diff --git a/devtools/client/framework/toolbox-host-manager.js b/devtools/client/framework/toolbox-host-manager.js new file mode 100644 index 000000000..1638f3a9a --- /dev/null +++ b/devtools/client/framework/toolbox-host-manager.js @@ -0,0 +1,244 @@ +const Services = require("Services"); +const {Ci} = require("chrome"); +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const {Task} = require("devtools/shared/task"); + +loader.lazyRequireGetter(this, "Toolbox", "devtools/client/framework/toolbox", true); +loader.lazyRequireGetter(this, "Hosts", "devtools/client/framework/toolbox-hosts", 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) + * - toggle-minimize-mode: + * When using the bottom host, the toolbox can be miximized to only display + * the tool titles + * - maximize-host: + * When using the bottom host in minimized mode, revert back to regular mode + * in order to see tool titles and the tools + * - 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: + * - host-minimized: + * The bottom host is done minimizing (after animation end) + * - host-maximized: + * The bottom host is done switching back to regular mode (after animation + * end) + * - 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(target, hostType, hostOptions) { + this.target = target; + + this.frameId = ID_COUNTER++; + + if (!hostType) { + hostType = Services.prefs.getCharPref(LAST_HOST); + } + this.onHostMinimized = this.onHostMinimized.bind(this); + this.onHostMaximized = this.onHostMaximized.bind(this); + this.host = this.createHost(hostType, hostOptions); + this.hostType = hostType; +} + +ToolboxHostManager.prototype = { + create: Task.async(function* (toolId) { + yield this.host.create(); + + this.host.frame.setAttribute("aria-label", L10N.getStr("toolbox.label")); + this.host.frame.ownerDocument.defaultView.addEventListener("message", this); + // We have to listen on capture as no event fires on bubble + this.host.frame.addEventListener("unload", this, true); + + let toolbox = new Toolbox(this.target, toolId, this.host.type, this.host.frame.contentWindow, this.frameId); + + // Prevent reloading the toolbox when loading the tools in a tab (e.g. from about:debugging) + if (!this.host.frame.contentWindow.location.href.startsWith("about:devtools-toolbox")) { + this.host.frame.setAttribute("src", "about:devtools-toolbox"); + } + + return toolbox; + }), + + handleEvent(event) { + switch(event.type) { + case "message": + this.onMessage(event); + break; + case "unload": + // On unload, host iframe already lost its contentWindow attribute, so + // we can only compare against locations. Here we filter two very + // different cases: preliminary about:blank document as well as iframes + // like tool iframes. + if (!event.target.location.href.startsWith("about:devtools-toolbox")) { + break; + } + // Don't destroy the host during unload event (esp., don't remove the + // iframe from DOM!). Otherwise the unload event for the toolbox + // document doesn't fire within the toolbox *document*! This is + // the unload event that fires on the toolbox *iframe*. + DevToolsUtils.executeSoon(() => { + this.destroy(); + }); + break; + } + }, + + onMessage(event) { + if (!event.data) { + return; + } + // Toolbox document is still chrome and disallow identifying message + // origin via event.source as it is null. So use a custom id. + if (event.data.frameId != this.frameId) { + return; + } + switch (event.data.name) { + case "switch-host": + this.switchHost(event.data.hostType); + break; + case "maximize-host": + this.host.maximize(); + break; + case "raise-host": + this.host.raise(); + break; + case "toggle-minimize-mode": + this.host.toggleMinimizeMode(event.data.toolbarHeight); + break; + case "set-host-title": + this.host.setTitle(event.data.title); + break; + } + }, + + postMessage(data) { + let window = this.host.frame.contentWindow; + window.postMessage(data, "*"); + }, + + destroy() { + this.destroyHost(); + this.host = null; + this.hostType = null; + this.target = 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); + } + + let newHost = new Hosts[hostType](this.target.tab, options); + // Update the label and icon when the state changes. + newHost.on("minimized", this.onHostMinimized); + newHost.on("maximized", this.onHostMaximized); + return newHost; + }, + + onHostMinimized() { + this.postMessage({ + name: "host-minimized" + }); + }, + + onHostMaximized() { + this.postMessage({ + name: "host-maximized" + }); + }, + + switchHost: Task.async(function* (hostType) { + 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 side if not. + if (hostType === this.hostType) { + if (hostType === Toolbox.HostType.BOTTOM) { + hostType = Toolbox.HostType.SIDE; + } else { + hostType = Toolbox.HostType.BOTTOM; + } + } + } + let iframe = this.host.frame; + let newHost = this.createHost(hostType); + let newIframe = yield newHost.create(); + // change toolbox document's parent to the new host + newIframe.swapFrameLoaders(iframe); + + this.destroyHost(); + + if (this.hostType != Toolbox.HostType.CUSTOM) { + Services.prefs.setCharPref(PREVIOUS_HOST, this.hostType); + } + + this.host = newHost; + this.hostType = hostType; + this.host.setTitle(this.host.frame.contentWindow.document.title); + this.host.frame.ownerDocument.defaultView.addEventListener("message", this); + this.host.frame.addEventListener("unload", this, true); + + if (hostType != Toolbox.HostType.CUSTOM) { + Services.prefs.setCharPref(LAST_HOST, hostType); + } + + // Tell the toolbox the host changed + this.postMessage({ + name: "switched-host", + hostType + }); + }), + + /** + * Destroy the current host, and remove event listeners from its frame. + * + * @return {promise} to be resolved when the host is destroyed. + */ + destroyHost() { + // When Firefox toplevel is closed, the frame may already be detached and + // the top level document gone + if (this.host.frame.ownerDocument.defaultView) { + this.host.frame.ownerDocument.defaultView.removeEventListener("message", this); + } + this.host.frame.removeEventListener("unload", this, true); + + this.host.off("minimized", this.onHostMinimized); + this.host.off("maximized", this.onHostMaximized); + 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 000000000..ea774549a --- /dev/null +++ b/devtools/client/framework/toolbox-hosts.js @@ -0,0 +1,425 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("devtools/shared/event-emitter"); +const promise = require("promise"); +const defer = require("devtools/shared/defer"); +const Services = require("Services"); +const {DOMHelpers} = require("resource://devtools/client/shared/DOMHelpers.jsm"); + +loader.lazyRequireGetter(this, "system", "devtools/shared/system"); + +/* 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.height to be larger than that, since it can cause problems with + * resizing the toolbox and panel layout. */ +const MIN_PAGE_SIZE = 25; + +/** + * 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 and emit a 'ready' event when the UI is ready to use + * destroy() - destroy the host's UI + */ + +exports.Hosts = { + "bottom": BottomHost, + "side": SidebarHost, + "window": WindowHost, + "custom": CustomHost +}; + +/** + * 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. + */ + create: function () { + let deferred = defer(); + + let gBrowser = this.hostTab.ownerDocument.defaultView.gBrowser; + let ownerDocument = gBrowser.ownerDocument; + this._nbox = gBrowser.getNotificationBox(this.hostTab.linkedBrowser); + + this._splitter = ownerDocument.createElement("splitter"); + this._splitter.setAttribute("class", "devtools-horizontal-splitter"); + // Avoid resizing notification containers + this._splitter.setAttribute("resizebefore", "flex"); + + this.frame = ownerDocument.createElement("iframe"); + this.frame.className = "devtools-toolbox-bottom-iframe"; + this.frame.height = Math.min( + Services.prefs.getIntPref(this.heightPref), + this._nbox.clientHeight - MIN_PAGE_SIZE + ); + + this._nbox.appendChild(this._splitter); + this._nbox.appendChild(this.frame); + + let frameLoad = () => { + this.emit("ready", this.frame); + deferred.resolve(this.frame); + }; + + this.frame.tooltip = "aHTMLTooltip"; + + // we have to load something so we can switch documents if we have to + this.frame.setAttribute("src", "about:blank"); + + let domHelper = new DOMHelpers(this.frame.contentWindow); + domHelper.onceDOMReady(frameLoad); + + focusTab(this.hostTab); + + return deferred.promise; + }, + + /** + * Raise the host. + */ + raise: function () { + focusTab(this.hostTab); + }, + + /** + * Minimize this host so that only the toolbox tabbar remains visible. + * @param {Number} height The height to minimize to. Defaults to 0, which + * means that the toolbox won't be visible at all once minimized. + */ + minimize: function (height = 0) { + if (this.isMinimized) { + return; + } + this.isMinimized = true; + + let onTransitionEnd = event => { + if (event.propertyName !== "margin-bottom") { + // Ignore transitionend on unrelated properties. + return; + } + + this.frame.removeEventListener("transitionend", onTransitionEnd); + this.emit("minimized"); + }; + this.frame.addEventListener("transitionend", onTransitionEnd); + this.frame.style.marginBottom = -this.frame.height + height + "px"; + this._splitter.classList.add("disabled"); + }, + + /** + * If the host was minimized before, maximize it again (the host will be + * maximized to the height it previously had). + */ + maximize: function () { + if (!this.isMinimized) { + return; + } + this.isMinimized = false; + + let onTransitionEnd = event => { + if (event.propertyName !== "margin-bottom") { + // Ignore transitionend on unrelated properties. + return; + } + + this.frame.removeEventListener("transitionend", onTransitionEnd); + this.emit("maximized"); + }; + this.frame.addEventListener("transitionend", onTransitionEnd); + this.frame.style.marginBottom = "0"; + this._splitter.classList.remove("disabled"); + }, + + /** + * Toggle the minimize mode. + * @param {Number} minHeight The height to minimize to. + */ + toggleMinimizeMode: function (minHeight) { + this.isMinimized ? this.maximize() : this.minimize(minHeight); + }, + + /** + * Set the toolbox title. + * Nothing to do for this host type. + */ + setTitle: function () {}, + + /** + * Destroy the bottom dock. + */ + destroy: function () { + if (!this._destroyed) { + this._destroyed = true; + + Services.prefs.setIntPref(this.heightPref, this.frame.height); + this._nbox.removeChild(this._splitter); + this._nbox.removeChild(this.frame); + this.frame = null; + this._nbox = null; + this._splitter = null; + } + + return promise.resolve(null); + } +}; + +/** + * Host object for the in-browser sidebar + */ +function SidebarHost(hostTab) { + this.hostTab = hostTab; + + EventEmitter.decorate(this); +} + +SidebarHost.prototype = { + type: "side", + + widthPref: "devtools.toolbox.sidebar.width", + + /** + * Create a box in the sidebar of the host tab. + */ + create: function () { + let deferred = defer(); + + let gBrowser = this.hostTab.ownerDocument.defaultView.gBrowser; + let ownerDocument = gBrowser.ownerDocument; + this._sidebar = gBrowser.getSidebarContainer(this.hostTab.linkedBrowser); + + this._splitter = ownerDocument.createElement("splitter"); + this._splitter.setAttribute("class", "devtools-side-splitter"); + + this.frame = ownerDocument.createElement("iframe"); + this.frame.className = "devtools-toolbox-side-iframe"; + + this.frame.width = Math.min( + Services.prefs.getIntPref(this.widthPref), + this._sidebar.clientWidth - MIN_PAGE_SIZE + ); + + this._sidebar.appendChild(this._splitter); + this._sidebar.appendChild(this.frame); + + let frameLoad = () => { + this.emit("ready", this.frame); + deferred.resolve(this.frame); + }; + + this.frame.tooltip = "aHTMLTooltip"; + this.frame.setAttribute("src", "about:blank"); + + let domHelper = new DOMHelpers(this.frame.contentWindow); + domHelper.onceDOMReady(frameLoad); + + focusTab(this.hostTab); + + return deferred.promise; + }, + + /** + * Raise the host. + */ + raise: function () { + focusTab(this.hostTab); + }, + + /** + * Set the toolbox title. + * Nothing to do for this host type. + */ + setTitle: function () {}, + + /** + * Destroy the sidebar. + */ + destroy: function () { + if (!this._destroyed) { + this._destroyed = true; + + Services.prefs.setIntPref(this.widthPref, this.frame.width); + this._sidebar.removeChild(this._splitter); + this._sidebar.removeChild(this.frame); + } + + return promise.resolve(null); + } +}; + +/** + * Host object for the toolbox in a separate window + */ +function WindowHost() { + this._boundUnload = this._boundUnload.bind(this); + + EventEmitter.decorate(this); +} + +WindowHost.prototype = { + type: "window", + + WINDOW_URL: "chrome://devtools/content/framework/toolbox-window.xul", + + /** + * Create a new xul window to contain the toolbox. + */ + create: function () { + let deferred = defer(); + + let flags = "chrome,centerscreen,resizable,dialog=no"; + let win = Services.ww.openWindow(null, this.WINDOW_URL, "_blank", + flags, null); + + let frameLoad = () => { + win.removeEventListener("load", frameLoad, true); + win.focus(); + + let key; + if (system.constants.platform === "macosx") { + key = win.document.getElementById("toolbox-key-toggle-osx"); + } else { + key = win.document.getElementById("toolbox-key-toggle"); + } + key.removeAttribute("disabled"); + + this.frame = win.document.getElementById("toolbox-iframe"); + this.emit("ready", this.frame); + + deferred.resolve(this.frame); + }; + + win.addEventListener("load", frameLoad, true); + win.addEventListener("unload", this._boundUnload); + + this._window = win; + + return deferred.promise; + }, + + /** + * Catch the user closing the window. + */ + _boundUnload: function (event) { + if (event.target.location != this.WINDOW_URL) { + return; + } + this._window.removeEventListener("unload", this._boundUnload); + + this.emit("window-closed"); + }, + + /** + * Raise the host. + */ + raise: function () { + this._window.focus(); + }, + + /** + * Set the toolbox title. + */ + setTitle: function (title) { + this._window.document.title = title; + }, + + /** + * Destroy the window. + */ + destroy: function () { + if (!this._destroyed) { + this._destroyed = true; + + this._window.removeEventListener("unload", this._boundUnload); + this._window.close(); + } + + return promise.resolve(null); + } +}; + +/** + * Host object for the toolbox in its own tab + */ +function CustomHost(hostTab, options) { + this.frame = options.customIframe; + this.uid = options.uid; + EventEmitter.decorate(this); +} + +CustomHost.prototype = { + type: "custom", + + _sendMessageToTopWindow: function (msg, data) { + // It's up to the custom frame owner (parent window) to honor + // "close" or "raise" instructions. + let topWindow = this.frame.ownerDocument.defaultView; + if (!topWindow) { + return; + } + let json = {name: "toolbox-" + msg, uid: this.uid}; + if (data) { + json.data = data; + } + topWindow.postMessage(JSON.stringify(json), "*"); + }, + + /** + * Create a new xul window to contain the toolbox. + */ + create: function () { + return promise.resolve(this.frame); + }, + + /** + * Raise the host. + */ + raise: function () { + this._sendMessageToTopWindow("raise"); + }, + + /** + * Set the toolbox title. + */ + setTitle: function (title) { + this._sendMessageToTopWindow("title", { value: title }); + }, + + /** + * Destroy the window. + */ + destroy: function () { + if (!this._destroyed) { + this._destroyed = true; + this._sendMessageToTopWindow("close"); + } + return promise.resolve(null); + } +}; + +/** + * Switch to the given tab in a browser and focus the browser window + */ +function focusTab(tab) { + let browserWindow = tab.ownerDocument.defaultView; + browserWindow.focus(); + browserWindow.gBrowser.selectedTab = tab; +} diff --git a/devtools/client/framework/toolbox-init.js b/devtools/client/framework/toolbox-init.js new file mode 100644 index 000000000..cb041c22d --- /dev/null +++ b/devtools/client/framework/toolbox-init.js @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// URL constructor doesn't support about: scheme +let href = window.location.href.replace("about:", "http://"); +let url = new window.URL(href); + +// Only use this method to attach the toolbox if some query parameters are given +if (url.search.length > 1) { + const Cu = Components.utils; + const Ci = Components.interfaces; + const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); + const { gDevTools } = require("devtools/client/framework/devtools"); + const { targetFromURL } = require("devtools/client/framework/target-from-url"); + const { Toolbox } = require("devtools/client/framework/toolbox"); + const { TargetFactory } = require("devtools/client/framework/target"); + const { DebuggerServer } = require("devtools/server/main"); + const { DebuggerClient } = require("devtools/shared/client/main"); + const { Task } = require("devtools/shared/task"); + + // `host` is the frame element loading the toolbox. + let host = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .containerElement; + + // Specify the default tool to open + let tool = url.searchParams.get("tool"); + + Task.spawn(function* () { + let target; + if (url.searchParams.has("target")) { + // Attach toolbox to a given browser iframe (<xul:browser> or <html:iframe + // mozbrowser>) whose reference is set on the host iframe. + + // `iframe` is the targeted document to debug + let iframe = host.wrappedJSObject ? host.wrappedJSObject.target + : host.target; + // Need to use a xray and query some interfaces to have + // attributes and behavior expected by devtools codebase + iframe = XPCNativeWrapper(iframe); + iframe.QueryInterface(Ci.nsIFrameLoaderOwner); + + if (iframe) { + // Fake a xul:tab object as we don't have one. + // linkedBrowser is the only one attribute being queried by client.getTab + let tab = { linkedBrowser: iframe }; + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + let client = new DebuggerClient(DebuggerServer.connectPipe()); + + yield client.connect(); + // Creates a target for a given browser iframe. + let response = yield client.getTab({ tab }); + let form = response.tab; + target = yield TargetFactory.forRemoteTab({client, form, chrome: false}); + } else { + alert("Unable to find the targetted iframe to debug"); + } + } else { + target = yield targetFromURL(url); + } + let options = { customIframe: host }; + yield gDevTools.showToolbox(target, tool, Toolbox.HostType.CUSTOM, options); + }).catch(error => { + console.error("Exception while loading the toolbox", error); + }); +} diff --git a/devtools/client/framework/toolbox-options.js b/devtools/client/framework/toolbox-options.js new file mode 100644 index 000000000..6362d98dd --- /dev/null +++ b/devtools/client/framework/toolbox-options.js @@ -0,0 +1,431 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Services = require("Services"); +const defer = require("devtools/shared/defer"); +const {Task} = require("devtools/shared/task"); +const {gDevTools} = require("devtools/client/framework/devtools"); + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties"); + +exports.OptionsPanel = OptionsPanel; + +function GetPref(name) { + let 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) { + let 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) { + this.panelDoc = iframeWindow.document; + this.panelWin = iframeWindow; + + this.toolbox = toolbox; + this.isReady = false; + + 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("devtools/shared/event-emitter"); + EventEmitter.decorate(this); +} + +OptionsPanel.prototype = { + + get target() { + return this.toolbox.target; + }, + + open: Task.async(function* () { + // For local debugging we need to make the target remote. + if (!this.target.isRemote) { + yield this.target.makeRemote(); + } + + this.setupToolsList(); + this.setupToolbarButtonsList(); + this.setupThemeList(); + yield this.populatePreferences(); + this.isReady = true; + this.emit("ready"); + return this; + }), + + _addListeners: function () { + Services.prefs.addObserver("devtools.cache.disabled", this._prefChanged, false); + Services.prefs.addObserver("devtools.theme", this._prefChanged, false); + gDevTools.on("theme-registered", this._themeRegistered); + gDevTools.on("theme-unregistered", this._themeUnregistered); + }, + + _removeListeners: function () { + Services.prefs.removeObserver("devtools.cache.disabled", this._prefChanged); + Services.prefs.removeObserver("devtools.theme", this._prefChanged); + gDevTools.off("theme-registered", this._themeRegistered); + gDevTools.off("theme-unregistered", this._themeUnregistered); + }, + + _prefChanged: function (subject, topic, prefName) { + if (prefName === "devtools.cache.disabled") { + let cacheDisabled = data.newValue; + let cbx = this.panelDoc.getElementById("devtools-disable-cache"); + + cbx.checked = cacheDisabled; + } else if (prefName === "devtools.theme") { + this.updateCurrentTheme(); + } + }, + + _themeRegistered: function (event, themeId) { + this.setupThemeList(); + }, + + _themeUnregistered: function (event, theme) { + let themeBox = this.panelDoc.getElementById("devtools-theme-box"); + let themeInput = themeBox.querySelector(`[value=${theme.id}]`); + + if (themeInput) { + themeInput.parentNode.remove(); + } + }, + + setupToolbarButtonsList: function () { + let enabledToolbarButtonsBox = this.panelDoc.getElementById( + "enabled-toolbox-buttons-box"); + + let toggleableButtons = this.toolbox.toolboxButtons; + let setToolboxButtonsVisibility = + this.toolbox.setToolboxButtonsVisibility.bind(this.toolbox); + + let onCheckboxClick = (checkbox) => { + let toolDefinition = toggleableButtons.filter( + toggleableButton => toggleableButton.id === checkbox.id)[0]; + Services.prefs.setBoolPref( + toolDefinition.visibilityswitch, checkbox.checked); + setToolboxButtonsVisibility(); + }; + + let createCommandCheckbox = tool => { + let checkboxLabel = this.panelDoc.createElement("label"); + let checkboxSpanLabel = this.panelDoc.createElement("span"); + checkboxSpanLabel.textContent = tool.label; + let checkboxInput = this.panelDoc.createElement("input"); + checkboxInput.setAttribute("type", "checkbox"); + checkboxInput.setAttribute("id", tool.id); + if (InfallibleGetBoolPref(tool.visibilityswitch)) { + checkboxInput.setAttribute("checked", true); + } + checkboxInput.addEventListener("change", + onCheckboxClick.bind(this, checkboxInput)); + + checkboxLabel.appendChild(checkboxInput); + checkboxLabel.appendChild(checkboxSpanLabel); + return checkboxLabel; + }; + + for (let tool of toggleableButtons) { + if (!tool.isTargetSupported(this.toolbox.target)) { + continue; + } + + enabledToolbarButtonsBox.appendChild(createCommandCheckbox(tool)); + } + }, + + setupToolsList: function () { + let defaultToolsBox = this.panelDoc.getElementById("default-tools-box"); + let additionalToolsBox = this.panelDoc.getElementById( + "additional-tools-box"); + let toolsNotSupportedLabel = this.panelDoc.getElementById( + "tools-not-supported-label"); + let atleastOneToolNotSupported = false; + + let onCheckboxClick = function (id) { + let toolDefinition = gDevTools._tools.get(id); + // Set the kill switch pref boolean to true + Services.prefs.setBoolPref(toolDefinition.visibilityswitch, this.checked); + if (this.checked) { + gDevTools.emit("tool-registered", id); + } else { + gDevTools.emit("tool-unregistered", toolDefinition); + } + }; + + let createToolCheckbox = tool => { + let checkboxLabel = this.panelDoc.createElement("label"); + let checkboxInput = this.panelDoc.createElement("input"); + checkboxInput.setAttribute("type", "checkbox"); + checkboxInput.setAttribute("id", tool.id); + checkboxInput.setAttribute("title", tool.tooltip || ""); + + let checkboxSpanLabel = this.panelDoc.createElement("span"); + if (tool.isTargetSupported(this.target)) { + 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, tool.id)); + + checkboxLabel.appendChild(checkboxInput); + checkboxLabel.appendChild(checkboxSpanLabel); + return checkboxLabel; + }; + + // Populating the default tools lists + let toggleableTools = gDevTools.getDefaultTools().filter(tool => { + return tool.visibilityswitch && !tool.hiddenInOptions; + }); + + for (let tool of toggleableTools) { + defaultToolsBox.appendChild(createToolCheckbox(tool)); + } + + // Populating the additional tools list that came from add-ons. + let atleastOneAddon = false; + for (let tool of gDevTools.getAdditionalTools()) { + atleastOneAddon = true; + additionalToolsBox.appendChild(createToolCheckbox(tool)); + } + + if (!atleastOneAddon) { + additionalToolsBox.style.display = "none"; + } + + if (!atleastOneToolNotSupported) { + toolsNotSupportedLabel.style.display = "none"; + } + + this.panelWin.focus(); + }, + + setupThemeList: function () { + let themeBox = this.panelDoc.getElementById("devtools-theme-box"); + let themeLabels = themeBox.querySelectorAll("label"); + for (let label of themeLabels) { + label.remove(); + } + + let createThemeOption = theme => { + let inputLabel = this.panelDoc.createElement("label"); + let 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) { + setPrefAndEmit(themeBox.getAttribute("data-pref"), + e.target.value); + }); + + let inputSpanLabel = this.panelDoc.createElement("span"); + inputSpanLabel.textContent = theme.label; + inputLabel.appendChild(inputRadio); + inputLabel.appendChild(inputSpanLabel); + + return inputLabel; + }; + + // Populating the default theme list + let themes = gDevTools.getThemeDefinitionArray(); + for (let theme of themes) { + themeBox.appendChild(createThemeOption(theme)); + } + + this.updateCurrentTheme(); + }, + + populatePreferences: function () { + let prefCheckboxes = this.panelDoc.querySelectorAll( + "input[type=checkbox][data-pref]"); + for (let prefCheckbox of prefCheckboxes) { + if (GetPref(prefCheckbox.getAttribute("data-pref"))) { + prefCheckbox.setAttribute("checked", true); + } + prefCheckbox.addEventListener("change", function (e) { + let checkbox = e.target; + setPrefAndEmit(checkbox.getAttribute("data-pref"), checkbox.checked); + }); + } + // Themes radio inputs are handled in setupThemeList + let prefRadiogroups = this.panelDoc.querySelectorAll( + ".radiogroup[data-pref]:not(#devtools-theme-box)"); + for (let radioGroup of prefRadiogroups) { + let selectedValue = GetPref(radioGroup.getAttribute("data-pref")); + + for (let radioInput of radioGroup.querySelectorAll("input[type=radio]")) { + if (radioInput.getAttribute("value") == selectedValue) { + radioInput.setAttribute("checked", true); + } + + radioInput.addEventListener("change", function (e) { + setPrefAndEmit(radioGroup.getAttribute("data-pref"), + e.target.value); + }); + } + } + let prefSelects = this.panelDoc.querySelectorAll("select[data-pref]"); + for (let prefSelect of prefSelects) { + let pref = GetPref(prefSelect.getAttribute("data-pref")); + let options = [...prefSelect.options]; + options.some(function (option) { + let value = option.value; + // non strict check to allow int values. + if (value == pref) { + prefSelect.selectedIndex = options.indexOf(option); + return true; + } + }); + + prefSelect.addEventListener("change", function (e) { + let select = e.target; + setPrefAndEmit(select.getAttribute("data-pref"), + select.options[select.selectedIndex].value); + }); + } + + if (this.target.activeTab) { + return this.target.client.attachTab(this.target.activeTab._actor) + .then(([response, client]) => { + this._origJavascriptEnabled = !response.javascriptEnabled; + this.disableJSNode.checked = this._origJavascriptEnabled; + this.disableJSNode.addEventListener("click", + this._disableJSClicked, false); + }); + } + this.disableJSNode.hidden = true; + }, + + updateCurrentTheme: function () { + let currentTheme = GetPref("devtools.theme"); + let themeBox = this.panelDoc.getElementById("devtools-theme-box"); + let themeRadioInput = themeBox.querySelector(`[value=${currentTheme}]`); + + if (themeRadioInput) { + themeRadioInput.checked = true; + } else { + // If the current theme does not exist anymore, switch to light theme + let lightThemeInputRadio = themeBox.querySelector("[value=light]"); + lightThemeInputRadio.checked = true; + } + }, + + /** + * Disables JavaScript for the currently loaded tab. We force a page refresh + * here because setting docShell.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: function (event) { + let checked = event.target.checked; + + let options = { + "javascriptEnabled": !checked + }; + + this.target.activeTab.reconfigure(options); + }, + + destroy: function () { + if (this.destroyPromise) { + return this.destroyPromise; + } + + let deferred = defer(); + this.destroyPromise = deferred.promise; + + this._removeListeners(); + + if (this.target.activeTab) { + this.disableJSNode.removeEventListener("click", this._disableJSClicked); + // FF41+ automatically cleans up state in actor on disconnect + if (!this.target.activeTab.traits.noTabReconfigureOnClose) { + let options = { + "javascriptEnabled": this._origJavascriptEnabled, + "performReload": false + }; + this.target.activeTab.reconfigure(options, deferred.resolve); + } else { + deferred.resolve(); + } + } else { + deferred.resolve(); + } + + this.panelWin = this.panelDoc = this.disableJSNode = this.toolbox = null; + + return this.destroyPromise; + } +}; + +/* Set a pref and emit the pref-changed event if needed. */ +function setPrefAndEmit(prefName, newValue) { + let data = { + pref: prefName, + newValue: newValue + }; + data.oldValue = GetPref(data.pref); + SetPref(data.pref, data.newValue); + + if (data.newValue != data.oldValue) { + gDevTools.emit("pref-changed", data); + } +} diff --git a/devtools/client/framework/toolbox-options.xhtml b/devtools/client/framework/toolbox-options.xhtml new file mode 100644 index 000000000..372a588ab --- /dev/null +++ b/devtools/client/framework/toolbox-options.xhtml @@ -0,0 +1,201 @@ +<?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 html [ +<!ENTITY % toolboxDTD SYSTEM "chrome://devtools/locale/toolbox.dtd" > + %toolboxDTD; +]> +<html xmlns="http://www.w3.org/1999/xhtml"> + <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" type="text/css"/> + <script type="application/javascript;version=1.8" src="chrome://devtools/content/shared/theme-switching.js"/> + </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>&options.selectDefaultTools.label2;</legend> + </fieldset> + + <fieldset id="additional-tools-box" class="options-groupbox"> + <legend>&options.selectAdditionalTools.label;</legend> + </fieldset> + + <fieldset id="enabled-toolbox-buttons-box" class="options-groupbox"> + <legend>&options.selectEnabledToolboxButtons.label;</legend> + <span id="tools-not-supported-label" + class="options-citation-label theme-comment"> + &options.toolNotSupported.label;</span> + </fieldset> + </div> + + <div class="options-vertical-pane"> + <fieldset id="devtools-theme-box" + class="options-groupbox + horizontal-options-groupbox + radiogroup" + data-pref="devtools.theme"> + <legend>&options.selectDevToolsTheme.label2;</legend> + </fieldset> + + <fieldset id="commonprefs-options" class="options-groupbox"> + <legend>&options.commonPrefs.label;</legend> + <label title="&options.enablePersistentLogs.tooltip;"> + <input type="checkbox" data-pref="devtools.webconsole.persistlog" /> + <span>&options.enablePersistentLogs.label;</span> + </label> + </fieldset> + + <fieldset id="inspector-options" class="options-groupbox"> + <legend>&options.context.inspector;</legend> + <label title="&options.showUserAgentStyles.tooltip;"> + <input type="checkbox" + data-pref="devtools.inspector.showUserAgentStyles"/> + <span>&options.showUserAgentStyles.label;</span> + </label> + <label title="&options.collapseAttrs.tooltip;"> + <input type="checkbox" + data-pref="devtools.markup.collapseAttributes"/> + <span>&options.collapseAttrs.label;</span> + </label> + <label> + <span>&options.defaultColorUnit.label;</span> + <select id="defaultColorUnitMenuList" + data-pref="devtools.defaultColorUnit"> + <option value="authored">&options.defaultColorUnit.authored;</option> + <option value="hex">&options.defaultColorUnit.hex;</option> + <option value="hsl">&options.defaultColorUnit.hsl;</option> + <option value="rgb">&options.defaultColorUnit.rgb;</option> + <option value="name">&options.defaultColorUnit.name;</option> + </select> + </label> + </fieldset> + + <fieldset id="webconsole-options" class="options-groupbox"> + <legend>&options.webconsole.label;</legend> + <label title="&options.timestampMessages.tooltip;"> + <input type="checkbox" + id="webconsole-timestamp-messages" + data-pref="devtools.webconsole.timestampMessages"/> + <span>&options.timestampMessages.label;</span> + </label> + </fieldset> + + <fieldset id="debugger-options" class="options-groupbox"> + <legend>&options.debugger.label;</legend> + <label title="&options.sourceMaps.tooltip;"> + <input type="checkbox" + id="debugger-sourcemaps" + data-pref="devtools.debugger.client-source-maps-enabled"/> + <span>&options.sourceMaps.label;</span> + </label> + </fieldset> + + <fieldset id="styleeditor-options" class="options-groupbox"> + <legend>&options.styleeditor.label;</legend> + <label title="&options.stylesheetSourceMaps.tooltip;"> + <input type="checkbox" + data-pref="devtools.styleeditor.source-maps-enabled"/> + <span>&options.stylesheetSourceMaps.label;</span> + </label> + <label title="&options.stylesheetAutocompletion.tooltip;"> + <input type="checkbox" + data-pref="devtools.styleeditor.autocompletion-enabled"/> + <span>&options.stylesheetAutocompletion.label;</span> + </label> + </fieldset> + </div> + + <div class="options-vertical-pane"> + <fieldset id="sourceeditor-options" class="options-groupbox"> + <legend>&options.sourceeditor.label;</legend> + <label title="&options.sourceeditor.detectindentation.tooltip;"> + <input type="checkbox" + id="devtools-sourceeditor-detectindentation" + data-pref="devtools.editor.detectindentation"/> + <span>&options.sourceeditor.detectindentation.label;</span> + </label> + <label title="&options.sourceeditor.autoclosebrackets.tooltip;"> + <input type="checkbox" + id="devtools-sourceeditor-autoclosebrackets" + data-pref="devtools.editor.autoclosebrackets"/> + <span>&options.sourceeditor.autoclosebrackets.label;</span> + </label> + <label title="&options.sourceeditor.expandtab.tooltip;"> + <input type="checkbox" + id="devtools-sourceeditor-expandtab" + data-pref="devtools.editor.expandtab"/> + <span>&options.sourceeditor.expandtab.label;</span> + </label> + <label> + <span>&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>&options.sourceeditor.keybinding.label;</span> + <select id="devtools-sourceeditor-keybinding-select" + data-pref="devtools.editor.keymap"> + <option value="default">&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>&options.context.advancedSettings;</legend> + <label title="&options.showPlatformData.tooltip;"> + <input type="checkbox" + id="devtools-show-gecko-data" + data-pref="devtools.performance.ui.show-platform-data"/> + <span>&options.showPlatformData.label;</span> + </label> + <label title="&options.disableHTTPCache.tooltip;"> + <input type="checkbox" + id="devtools-disable-cache" + data-pref="devtools.cache.disabled"/> + <span>&options.disableHTTPCache.label;</span> + </label> + <label title="&options.disableJavaScript.tooltip;"> + <input type="checkbox" + id="devtools-disable-javascript"/> + <span>&options.disableJavaScript.label;</span> + </label> + <label title="&options.enableServiceWorkersHTTP.tooltip;"> + <input type="checkbox" + id="devtools-enable-serviceWorkersTesting" + data-pref="devtools.serviceWorkers.testing.enabled"/> + <span>&options.enableServiceWorkersHTTP.label;</span> + </label> + <label title="&options.enableChrome.tooltip3;"> + <input type="checkbox" + data-pref="devtools.chrome.enabled"/> + <span>&options.enableChrome.label5;</span> + </label> + <label title="&options.enableRemote.tooltip2;"> + <input type="checkbox" + data-pref="devtools.debugger.remote-enabled"/> + <span>&options.enableRemote.label3;</span> + </label> + <label title="&options.enableWorkers.tooltip;"> + <input type="checkbox" + data-pref="devtools.debugger.workers"/> + <span>&options.enableWorkers.label;</span> + </label> + <span class="options-citation-label theme-comment" + >&options.context.triggersPageRefresh;</span> + </fieldset> + </div> + + </form> + </body> +</html> diff --git a/devtools/client/framework/toolbox-process-window.js b/devtools/client/framework/toolbox-process-window.js new file mode 100644 index 000000000..8ead718b3 --- /dev/null +++ b/devtools/client/framework/toolbox-process-window.js @@ -0,0 +1,230 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +var { loader, require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); +// Require this module to setup core modules +loader.require("devtools/client/framework/devtools-browser"); + +var { gDevTools } = require("devtools/client/framework/devtools"); +var { TargetFactory } = require("devtools/client/framework/target"); +var { Toolbox } = require("devtools/client/framework/toolbox"); +var Services = require("Services"); +var { DebuggerClient } = require("devtools/shared/client/main"); +var { PrefsHelper } = require("devtools/client/shared/prefs"); +var { Task } = require("devtools/shared/task"); + +/** + * Shortcuts for accessing various debugger preferences. + */ +var Prefs = new PrefsHelper("devtools.debugger", { + chromeDebuggingHost: ["Char", "chrome-debugging-host"], + chromeDebuggingPort: ["Int", "chrome-debugging-port"], + chromeDebuggingWebSocket: ["Bool", "chrome-debugging-websocket"], +}); + +var gToolbox, gClient; + +var connect = Task.async(function*() { + window.removeEventListener("load", connect); + // Initiate the connection + let transport = yield DebuggerClient.socketConnect({ + host: Prefs.chromeDebuggingHost, + port: Prefs.chromeDebuggingPort, + webSocket: Prefs.chromeDebuggingWebSocket, + }); + gClient = new DebuggerClient(transport); + yield gClient.connect(); + let addonID = getParameterByName("addonID"); + + if (addonID) { + let { addons } = yield gClient.listAddons(); + let addonActor = addons.filter(addon => addon.id === addonID).pop(); + openToolbox({ + form: addonActor, + chrome: true, + isTabActor: addonActor.isWebExtension ? true : false + }); + } else { + let response = yield gClient.getProcess(); + openToolbox({ + form: response.form, + chrome: true + }); + } +}); + +// 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.performance.ui.show-platform-data", true); + Services.prefs.setBoolPref("devtools.inspector.showAllAnonymousContent", true); + Services.prefs.setBoolPref("browser.dom.window.dump.enabled", true); + Services.prefs.setBoolPref("devtools.command-button-noautohide.enabled", true); + Services.prefs.setBoolPref("devtools.scratchpad.enabled", true); + // Bug 1225160 - Using source maps with browser debugging can lead to a crash + Services.prefs.setBoolPref("devtools.debugger.source-maps-enabled", false); + Services.prefs.setBoolPref("devtools.debugger.new-debugger-frontend", false); + Services.prefs.setBoolPref("devtools.debugger.client-source-maps-enabled", true); +} + +window.addEventListener("load", function() { + let cmdClose = document.getElementById("toolbox-cmd-close"); + cmdClose.addEventListener("command", onCloseCommand); + setPrefDefaults(); + connect().catch(e => { + let errorMessageContainer = document.getElementById("error-message-container"); + let errorMessage = document.getElementById("error-message"); + errorMessage.value = e.message || e; + errorMessageContainer.hidden = false; + console.error(e); + }); +}); + +function onCloseCommand(event) { + window.close(); +} + +function openToolbox({ form, chrome, isTabActor }) { + let options = { + form: form, + client: gClient, + chrome: chrome, + isTabActor: isTabActor + }; + TargetFactory.forRemoteTab(options).then(target => { + let frame = document.getElementById("toolbox-iframe"); + let selectedTool = "jsdebugger"; + + try { + // Remember the last panel that was used inside of this profile. + selectedTool = Services.prefs.getCharPref("devtools.toolbox.selectedTool"); + } catch(e) {} + + try { + // But if we are testing, then it should always open the debugger panel. + selectedTool = Services.prefs.getCharPref("devtools.browsertoolbox.panel"); + } catch(e) {} + + let options = { customIframe: frame }; + gDevTools.showToolbox(target, + selectedTool, + Toolbox.HostType.CUSTOM, + options) + .then(onNewToolbox); + }); +} + +function onNewToolbox(toolbox) { + gToolbox = toolbox; + bindToolboxHandlers(); + raise(); + let env = Components.classes["@mozilla.org/process/environment;1"].getService(Components.interfaces.nsIEnvironment); + let testScript = env.get("MOZ_TOOLBOX_TEST_SCRIPT"); + if (testScript) { + // Only allow executing random chrome scripts when a special + // test-only pref is set + let prefName = "devtools.browser-toolbox.allow-unsafe-script"; + if (Services.prefs.getPrefType(prefName) == Services.prefs.PREF_BOOL && + Services.prefs.getBoolPref(prefName) === true) { + evaluateTestScript(testScript, toolbox); + } + } +} + +function evaluateTestScript(script, toolbox) { + let sandbox = Cu.Sandbox(window); + sandbox.window = window; + sandbox.toolbox = toolbox; + Cu.evalInSandbox(script, sandbox); +} + +function bindToolboxHandlers() { + gToolbox.once("destroyed", quitApp); + window.addEventListener("unload", onUnload); + +#ifdef XP_MACOSX + // Badge the dock icon to differentiate this process from the main application process. + updateBadgeText(false); + + // Once the debugger panel opens listen for thread pause / resume. + gToolbox.getPanelWhenReady("jsdebugger").then(panel => { + setupThreadListeners(panel); + }); +#endif +} + +function setupThreadListeners(panel) { + updateBadgeText(panel._controller.activeThread.state == "paused"); + + let onPaused = updateBadgeText.bind(null, true); + let onResumed = updateBadgeText.bind(null, false); + panel.target.on("thread-paused", onPaused); + panel.target.on("thread-resumed", onResumed); + + panel.once("destroyed", () => { + panel.off("thread-paused", onPaused); + panel.off("thread-resumed", onResumed); + }); +} + +function updateBadgeText(paused) { + let dockSupport = Cc["@mozilla.org/widget/macdocksupport;1"].getService(Ci.nsIMacDockSupport); + dockSupport.badgeText = paused ? "▐▐ " : " ▶"; +} + +function onUnload() { + window.removeEventListener("unload", onUnload); + window.removeEventListener("message", onMessage); + let cmdClose = document.getElementById("toolbox-cmd-close"); + cmdClose.removeEventListener("command", onCloseCommand); + gToolbox.destroy(); +} + +function onMessage(event) { + try { + let json = JSON.parse(event.data); + switch (json.name) { + case "toolbox-raise": + raise(); + break; + case "toolbox-title": + setTitle(json.data.value); + break; + } + } catch(e) { console.error(e); } +} + +window.addEventListener("message", onMessage); + +function raise() { + window.focus(); +} + +function setTitle(title) { + document.title = title; +} + +function quitApp() { + let quit = Cc["@mozilla.org/supports-PRBool;1"] + .createInstance(Ci.nsISupportsPRBool); + Services.obs.notifyObservers(quit, "quit-application-requested", null); + + let shouldProceed = !quit.data; + if (shouldProceed) { + Services.startup.quit(Ci.nsIAppStartup.eForceQuit); + } +} + +function getParameterByName (name) { + name = name.replace(/[\[]/, "\\\[").replace(/[\]]/, "\\\]"); + let regex = new RegExp("[\\?&]" + name + "=([^&#]*)"); + let results = regex.exec(window.location.search); + return results == null ? "" : decodeURIComponent(results[1].replace(/\+/g, " ")); +} diff --git a/devtools/client/framework/toolbox-process-window.xul b/devtools/client/framework/toolbox-process-window.xul new file mode 100644 index 000000000..d2f8a741b --- /dev/null +++ b/devtools/client/framework/toolbox-process-window.xul @@ -0,0 +1,47 @@ +<?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 [ +<!ENTITY % toolboxDTD SYSTEM "chrome://devtools/locale/toolbox.dtd" > + %toolboxDTD; +]> + +<?xml-stylesheet href="chrome://global/skin/" type="text/css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + id="devtools-toolbox-window" + macanimationtype="document" + fullscreenbutton="true" + windowtype="devtools:toolbox" + width="900" height="600" + persist="screenX screenY width height sizemode"> + + <script type="text/javascript" src="chrome://global/content/globalOverlay.js"/> + <script type="text/javascript" src="toolbox-process-window.js"/> + <script type="text/javascript" src="chrome://global/content/viewSourceUtils.js"/> + <script type="text/javascript" src="chrome://browser/content/utilityOverlay.js"/> + + <commandset id="toolbox-commandset"> + <command id="toolbox-cmd-close"/> + </commandset> + + <keyset id="toolbox-keyset"> + <key id="toolbox-key-close" + key="&closeCmd.key;" + command="toolbox-cmd-close" + modifiers="accel"/> + </keyset> + + <!-- This will be used by the Web Console to hold any popups it may create, + for example when viewing network request details. --> + <popupset id="mainPopupSet"></popupset> + + <vbox id="error-message-container" hidden="true" flex="1"> + <box>&browserToolboxErrorMessage;</box> + <textbox multiline="true" id="error-message" flex="1"></textbox> + </vbox> + + <tooltip id="aHTMLTooltip" page="true"/> + <iframe id="toolbox-iframe" flex="1" tooltip="aHTMLTooltip"></iframe> +</window> diff --git a/devtools/client/framework/toolbox-window.xul b/devtools/client/framework/toolbox-window.xul new file mode 100644 index 000000000..cd14a3597 --- /dev/null +++ b/devtools/client/framework/toolbox-window.xul @@ -0,0 +1,47 @@ +<?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 [ +<!ENTITY % toolboxDTD SYSTEM "chrome://devtools/locale/toolbox.dtd" > + %toolboxDTD; +]> + +<?xml-stylesheet href="chrome://global/skin/" type="text/css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + id="devtools-toolbox-window" + macanimationtype="document" + fullscreenbutton="true" + windowtype="devtools:toolbox" + width="900" height="320" + persist="screenX screenY width height sizemode"> + + <commandset id="toolbox-commandset"> + <command id="toolbox-cmd-close" oncommand="window.close();"/> + </commandset> + + <keyset id="toolbox-keyset"> + <key id="toolbox-key-close" + key="&closeCmd.key;" + command="toolbox-cmd-close" + modifiers="accel"/> + <key id="toolbox-key-toggle" + key="&toggleToolbox.key;" + command="toolbox-cmd-close" + modifiers="accel,shift" + disabled="true"/> + <key id="toolbox-key-toggle-osx" + key="&toggleToolbox.key;" + command="toolbox-cmd-close" + modifiers="accel,alt" + disabled="true"/> + <key id="toolbox-key-toggle-F12" + keycode="&toggleToolboxF12.keycode;" + keytext="&toggleToolboxF12.keytext;" + command="toolbox-cmd-close"/> + </keyset> + + <tooltip id="aHTMLTooltip" page="true"/> + <iframe id="toolbox-iframe" flex="1" forceOwnRefreshDriver="" tooltip="aHTMLTooltip"></iframe> +</window> diff --git a/devtools/client/framework/toolbox.js b/devtools/client/framework/toolbox.js new file mode 100644 index 000000000..82d5d2915 --- /dev/null +++ b/devtools/client/framework/toolbox.js @@ -0,0 +1,2417 @@ +/* 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 OS_HISTOGRAM = "DEVTOOLS_OS_ENUMERATED_PER_USER"; +const OS_IS_64_BITS = "DEVTOOLS_OS_IS_64_BITS_PER_USER"; +const HOST_HISTOGRAM = "DEVTOOLS_TOOLBOX_HOST"; +const SCREENSIZE_HISTOGRAM = "DEVTOOLS_SCREEN_RESOLUTION_ENUMERATED_PER_USER"; +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const { SourceMapService } = require("./source-map-service"); + +var {Ci, Cu} = require("chrome"); +var promise = require("promise"); +var defer = require("devtools/shared/defer"); +var Services = require("Services"); +var {Task} = require("devtools/shared/task"); +var {gDevTools} = require("devtools/client/framework/devtools"); +var EventEmitter = require("devtools/shared/event-emitter"); +var Telemetry = require("devtools/client/shared/telemetry"); +var HUDService = require("devtools/client/webconsole/hudservice"); +var viewSource = require("devtools/client/shared/view-source"); +var { attachThread, detachThread } = require("./attach-thread"); +var Menu = require("devtools/client/framework/menu"); +var MenuItem = require("devtools/client/framework/menu-item"); +var { DOMHelpers } = require("resource://devtools/client/shared/DOMHelpers.jsm"); +const { KeyCodes } = require("devtools/client/shared/keycodes"); + +const { BrowserLoader } = + Cu.import("resource://devtools/client/shared/browser-loader.js", {}); + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties"); + +loader.lazyRequireGetter(this, "CommandUtils", + "devtools/client/shared/developer-toolbar", true); +loader.lazyRequireGetter(this, "getHighlighterUtils", + "devtools/client/framework/toolbox-highlighter-utils", true); +loader.lazyRequireGetter(this, "Selection", + "devtools/client/framework/selection", true); +loader.lazyRequireGetter(this, "InspectorFront", + "devtools/shared/fronts/inspector", true); +loader.lazyRequireGetter(this, "flags", + "devtools/shared/flags"); +loader.lazyRequireGetter(this, "showDoorhanger", + "devtools/client/shared/doorhanger", true); +loader.lazyRequireGetter(this, "createPerformanceFront", + "devtools/shared/fronts/performance", true); +loader.lazyRequireGetter(this, "system", + "devtools/shared/system"); +loader.lazyRequireGetter(this, "getPreferenceFront", + "devtools/shared/fronts/preference", true); +loader.lazyRequireGetter(this, "KeyShortcuts", + "devtools/client/shared/key-shortcuts", true); +loader.lazyRequireGetter(this, "ZoomKeys", + "devtools/client/shared/zoom-keys"); +loader.lazyRequireGetter(this, "settleAll", + "devtools/shared/ThreadSafeDevToolsUtils", true); +loader.lazyRequireGetter(this, "ToolboxButtons", + "devtools/client/definitions", true); + +loader.lazyGetter(this, "registerHarOverlay", () => { + return require("devtools/client/netmonitor/har/toolbox-overlay").register; +}); + +/** + * 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} target + * The object the toolbox is debugging. + * @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(target, selectedTool, hostType, contentWindow, frameId) { + this._target = target; + this._win = contentWindow; + this.frameId = frameId; + + this._toolPanels = new Map(); + this._telemetry = new Telemetry(); + if (Services.prefs.getBoolPref("devtools.sourcemap.locations.enabled")) { + this._sourceMapService = new SourceMapService(this._target); + } + + this._initInspector = null; + this._inspector = null; + + // Map of frames (id => frame-info) and currently selected frame id. + this.frameMap = new Map(); + this.selectedFrameId = null; + + this._toolRegistered = this._toolRegistered.bind(this); + this._toolUnregistered = this._toolUnregistered.bind(this); + this._refreshHostTitle = this._refreshHostTitle.bind(this); + this._toggleAutohide = this._toggleAutohide.bind(this); + this.showFramesMenu = this.showFramesMenu.bind(this); + this._updateFrames = this._updateFrames.bind(this); + this._splitConsoleOnKeypress = this._splitConsoleOnKeypress.bind(this); + this.destroy = this.destroy.bind(this); + this.highlighterUtils = getHighlighterUtils(this); + this._highlighterReady = this._highlighterReady.bind(this); + this._highlighterHidden = this._highlighterHidden.bind(this); + this._prefChanged = this._prefChanged.bind(this); + this._saveSplitConsoleHeight = this._saveSplitConsoleHeight.bind(this); + this._onFocus = this._onFocus.bind(this); + this._onBrowserMessage = this._onBrowserMessage.bind(this); + this._showDevEditionPromo = this._showDevEditionPromo.bind(this); + this._updateTextBoxMenuItems = this._updateTextBoxMenuItems.bind(this); + this._onBottomHostMinimized = this._onBottomHostMinimized.bind(this); + this._onBottomHostMaximized = this._onBottomHostMaximized.bind(this); + this._onToolSelectWhileMinimized = this._onToolSelectWhileMinimized.bind(this); + this._onPerformanceFrontEvent = this._onPerformanceFrontEvent.bind(this); + this._onBottomHostWillChange = this._onBottomHostWillChange.bind(this); + this._toggleMinimizeMode = this._toggleMinimizeMode.bind(this); + this._onTabbarFocus = this._onTabbarFocus.bind(this); + this._onTabbarArrowKeypress = this._onTabbarArrowKeypress.bind(this); + this._onPickerClick = this._onPickerClick.bind(this); + this._onPickerKeypress = this._onPickerKeypress.bind(this); + this._onPickerStarted = this._onPickerStarted.bind(this); + this._onPickerStopped = this._onPickerStopped.bind(this); + + this._target.on("close", this.destroy); + + if (!selectedTool) { + selectedTool = Services.prefs.getCharPref(this._prefs.LAST_TOOL); + } + this._defaultToolId = selectedTool; + + this._hostType = hostType; + + EventEmitter.decorate(this); + + this._target.on("navigate", this._refreshHostTitle); + this._target.on("frame-update", this._updateFrames); + + this.on("host-changed", this._refreshHostTitle); + this.on("select", this._refreshHostTitle); + + this.on("ready", this._showDevEditionPromo); + + gDevTools.on("tool-registered", this._toolRegistered); + gDevTools.on("tool-unregistered", this._toolUnregistered); + + this.on("picker-started", this._onPickerStarted); + this.on("picker-stopped", this._onPickerStopped); +} +exports.Toolbox = Toolbox; + +/** + * The toolbox can be 'hosted' either embedded in a browser window + * or in a separate window. + */ +Toolbox.HostType = { + BOTTOM: "bottom", + SIDE: "side", + WINDOW: "window", + CUSTOM: "custom" +}; + +Toolbox.prototype = { + _URL: "about:devtools-toolbox", + + _prefs: { + LAST_TOOL: "devtools.toolbox.selectedTool", + SIDE_ENABLED: "devtools.toolbox.sideEnabled", + }, + + currentToolId: null, + lastUsedToolId: null, + + /** + * Returns a *copy* of the _toolPanels collection. + * + * @return {Map} panels + * All the running panels in the toolbox + */ + getToolPanels: function () { + return new Map(this._toolPanels); + }, + + /** + * Access the panel for a given tool + */ + getPanel: function (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: function (id) { + let deferred = defer(); + let panel = this.getPanel(id); + if (panel) { + deferred.resolve(panel); + } else { + this.on(id + "-ready", (e, initializedPanel) => { + deferred.resolve(initializedPanel); + }); + } + + return deferred.promise; + }, + + /** + * 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: function () { + return this._toolPanels.get(this.currentToolId); + }, + + /** + * Get/alter the target of a Toolbox so we're debugging something different. + * See Target.jsm for more details. + * TODO: Do we allow |toolbox.target = null;| ? + */ + get target() { + return this._target; + }, + + get threadClient() { + return this._threadClient; + }, + + /** + * 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; + }, + + /** + * Shortcut to the document containing the toolbox UI + */ + get doc() { + return this.win.document; + }, + + /** + * Get the toolbox highlighter front. Note that it may not always have been + * initialized first. Use `initInspector()` if needed. + * Consider using highlighterUtils instead, it exposes the highlighter API in + * a useful way for the toolbox panels + */ + get highlighter() { + return this._highlighter; + }, + + /** + * Get the toolbox's performance front. Note that it may not always have been + * initialized first. Use `initPerformance()` if needed. + */ + get performance() { + return this._performance; + }, + + /** + * Get the toolbox's inspector front. Note that it may not always have been + * initialized first. Use `initInspector()` if needed. + */ + get inspector() { + return this._inspector; + }, + + /** + * Get the toolbox's walker front. Note that it may not always have been + * initialized first. Use `initInspector()` if needed. + */ + get walker() { + return this._walker; + }, + + /** + * Get the toolbox's node selection. Note that it may not always have been + * initialized first. Use `initInspector()` if needed. + */ + get selection() { + return this._selection; + }, + + /** + * Get the toggled state of the split console + */ + get splitConsole() { + return this._splitConsole; + }, + + /** + * Get the focused state of the split console + */ + isSplitConsoleFocused: function () { + if (!this._splitConsole) { + return false; + } + let focusedWin = Services.focus.focusedWindow; + return focusedWin && focusedWin === + this.doc.querySelector("#toolbox-panel-iframe-webconsole").contentWindow; + }, + + /** + * Open the toolbox + */ + open: function () { + return Task.spawn(function* () { + this.browserRequire = BrowserLoader({ + window: this.doc.defaultView, + useOnlyShared: true + }).require; + + if (this.win.location.href.startsWith(this._URL)) { + // Update the URL so that onceDOMReady watch for the right url. + this._URL = this.win.location.href; + } + + let domReady = defer(); + let domHelper = new DOMHelpers(this.win); + domHelper.onceDOMReady(() => { + domReady.resolve(); + }, this._URL); + + // Optimization: fire up a few other things before waiting on + // the iframe being ready (makes startup faster) + + // Load the toolbox-level actor fronts and utilities now + yield this._target.makeRemote(); + + // Attach the thread + this._threadClient = yield attachThread(this); + yield domReady.promise; + + this.isReady = true; + let framesPromise = this._listFrames(); + + this.closeButton = this.doc.getElementById("toolbox-close"); + this.closeButton.addEventListener("click", this.destroy, true); + + gDevTools.on("pref-changed", this._prefChanged); + + let framesMenu = this.doc.getElementById("command-button-frames"); + framesMenu.addEventListener("click", this.showFramesMenu, false); + + let noautohideMenu = this.doc.getElementById("command-button-noautohide"); + noautohideMenu.addEventListener("click", this._toggleAutohide, true); + + this.textBoxContextMenuPopup = + this.doc.getElementById("toolbox-textbox-context-popup"); + this.textBoxContextMenuPopup.addEventListener("popupshowing", + this._updateTextBoxMenuItems, true); + + this.shortcuts = new KeyShortcuts({ + window: this.doc.defaultView + }); + this._buildDockButtons(); + this._buildOptions(); + this._buildTabs(); + this._applyCacheSettings(); + this._applyServiceWorkersTestingSettings(); + this._addKeysToWindow(); + this._addReloadKeys(); + this._addHostListeners(); + this._registerOverlays(); + if (!this._hostOptions || this._hostOptions.zoom === true) { + ZoomKeys.register(this.win); + } + + this.tabbar = this.doc.querySelector(".devtools-tabbar"); + this.tabbar.addEventListener("focus", this._onTabbarFocus, true); + this.tabbar.addEventListener("click", this._onTabbarFocus, true); + this.tabbar.addEventListener("keypress", this._onTabbarArrowKeypress); + + this.webconsolePanel = this.doc.querySelector("#toolbox-panel-webconsole"); + this.webconsolePanel.height = Services.prefs.getIntPref(SPLITCONSOLE_HEIGHT_PREF); + this.webconsolePanel.addEventListener("resize", this._saveSplitConsoleHeight); + + let buttonsPromise = this._buildButtons(); + + this._pingTelemetry(); + + // The isTargetSupported check needs to happen after the target is + // remoted, otherwise we could have done it in the toolbox constructor + // (bug 1072764). + let toolDef = gDevTools.getToolDefinition(this._defaultToolId); + if (!toolDef || !toolDef.isTargetSupported(this._target)) { + this._defaultToolId = "webconsole"; + } + + yield this.selectTool(this._defaultToolId); + + // 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(); + } + + yield promise.all([ + splitConsolePromise, + buttonsPromise, + framesPromise + ]); + + // Lazily connect to the profiler here and don't wait for it to complete, + // used to intercept console.profile calls before the performance tools are open. + let performanceFrontConnection = this.initPerformance(); + + // If in testing environment, wait for performance connection to finish, + // so we don't have to explicitly wait for this in tests; ideally, all tests + // will handle this on their own, but each have their own tear down function. + if (flags.testing) { + yield performanceFrontConnection; + } + + this.emit("ready"); + }.bind(this)).then(null, console.error.bind(console)); + }, + + /** + * 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"); + }, + + // Return HostType id for telemetry + _getTelemetryHostId: function () { + switch (this.hostType) { + case Toolbox.HostType.BOTTOM: return 0; + case Toolbox.HostType.SIDE: return 1; + case Toolbox.HostType.WINDOW: return 2; + case Toolbox.HostType.CUSTOM: return 3; + default: return 9; + } + }, + + _pingTelemetry: function () { + this._telemetry.toolOpened("toolbox"); + + this._telemetry.logOncePerBrowserVersion(OS_HISTOGRAM, system.getOSCPU()); + this._telemetry.logOncePerBrowserVersion(OS_IS_64_BITS, + Services.appinfo.is64Bit ? 1 : 0); + this._telemetry.logOncePerBrowserVersion(SCREENSIZE_HISTOGRAM, + system.getScreenDimensions()); + this._telemetry.log(HOST_HISTOGRAM, this._getTelemetryHostId()); + }, + + /** + * Because our panels are lazy loaded this is a good place to watch for + * "pref-changed" events. + * @param {String} event + * The event type, "pref-changed". + * @param {Object} data + * { + * newValue: The new value + * oldValue: The old value + * pref: The name of the preference that has changed + * } + */ + _prefChanged: function (event, data) { + switch (data.pref) { + case "devtools.cache.disabled": + this._applyCacheSettings(); + break; + case "devtools.serviceWorkers.testing.enabled": + this._applyServiceWorkersTestingSettings(); + break; + } + }, + + _buildOptions: function () { + let selectOptions = (name, 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); + } else { + this.selectTool("options"); + } + // Prevent the opening of bookmarks window on toolbox.options.key + event.preventDefault(); + }; + this.shortcuts.on(L10N.getStr("toolbox.options.key"), selectOptions); + this.shortcuts.on(L10N.getStr("toolbox.help.key"), selectOptions); + }, + + _splitConsoleOnKeypress: function (e) { + if (e.keyCode === KeyCodes.DOM_VK_ESCAPE) { + this.toggleSplitConsole(); + // If the debugger is paused, don't let the ESC key stop any pending + // navigation. + if (this._threadClient.state == "paused") { + 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: function (key, handler, whichTool) { + this.shortcuts.on(key, (name, event) => { + if (this.currentToolId === whichTool && this.isSplitConsoleFocused()) { + handler(); + event.preventDefault(); + } + }); + }, + + _addReloadKeys: function () { + [ + ["reload", false], + ["reload2", false], + ["forceReload", true], + ["forceReload2", true] + ].forEach(([id, force]) => { + let key = L10N.getStr("toolbox." + id + ".key"); + this.shortcuts.on(key, (name, event) => { + this.reloadTarget(force); + + // Prevent Firefox shortcuts from reloading the page + event.preventDefault(); + }); + }); + }, + + _addHostListeners: function () { + this.shortcuts.on(L10N.getStr("toolbox.nextTool.key"), + (name, event) => { + this.selectNextTool(); + event.preventDefault(); + }); + this.shortcuts.on(L10N.getStr("toolbox.previousTool.key"), + (name, event) => { + this.selectPreviousTool(); + event.preventDefault(); + }); + this.shortcuts.on(L10N.getStr("toolbox.minimize.key"), + (name, event) => { + this._toggleMinimizeMode(); + event.preventDefault(); + }); + this.shortcuts.on(L10N.getStr("toolbox.toggleHost.key"), + (name, event) => { + this.switchToPreviousHost(); + event.preventDefault(); + }); + + this.doc.addEventListener("keypress", this._splitConsoleOnKeypress, false); + this.doc.addEventListener("focus", this._onFocus, true); + this.win.addEventListener("unload", this.destroy); + this.win.addEventListener("message", this._onBrowserMessage, true); + }, + + _removeHostListeners: function () { + // The host iframe's contentDocument may already be gone. + if (this.doc) { + this.doc.removeEventListener("keypress", this._splitConsoleOnKeypress, false); + this.doc.removeEventListener("focus", this._onFocus, true); + this.win.removeEventListener("unload", this.destroy); + this.win.removeEventListener("message", this._onBrowserMessage, true); + } + }, + + // Called whenever the chrome send a message + _onBrowserMessage: function (event) { + if (!event.data) { + return; + } + switch (event.data.name) { + case "switched-host": + this._onSwitchedHost(event.data); + break; + case "host-minimized": + if (this.hostType == Toolbox.HostType.BOTTOM) { + this._onBottomHostMinimized(); + } + break; + case "host-maximized": + if (this.hostType == Toolbox.HostType.BOTTOM) { + this._onBottomHostMaximized(); + } + break; + } + }, + + _registerOverlays: function () { + registerHarOverlay(this); + }, + + _saveSplitConsoleHeight: function () { + Services.prefs.setIntPref(SPLITCONSOLE_HEIGHT_PREF, + this.webconsolePanel.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: function () { + let deck = this.doc.getElementById("toolbox-deck"); + let webconsolePanel = this.webconsolePanel; + let splitter = this.doc.getElementById("toolbox-console-splitter"); + let openedConsolePanel = this.currentToolId === "webconsole"; + + if (openedConsolePanel) { + deck.setAttribute("collapsed", "true"); + splitter.setAttribute("hidden", "true"); + webconsolePanel.removeAttribute("collapsed"); + } else { + deck.removeAttribute("collapsed"); + if (this.splitConsole) { + webconsolePanel.removeAttribute("collapsed"); + splitter.removeAttribute("hidden"); + } else { + webconsolePanel.setAttribute("collapsed", "true"); + splitter.setAttribute("hidden", "true"); + } + } + }, + + /** + * Adds the keys and commands to the Toolbox Window in window mode. + */ + _addKeysToWindow: function () { + if (this.hostType != Toolbox.HostType.WINDOW) { + return; + } + + let doc = this.win.parent.document; + + for (let [id, toolDefinition] of gDevTools.getToolDefinitionMap()) { + // Prevent multiple entries for the same tool. + if (!toolDefinition.key || doc.getElementById("key_" + id)) { + continue; + } + + let toolId = id; + let key = doc.createElement("key"); + + key.id = "key_" + toolId; + + if (toolDefinition.key.startsWith("VK_")) { + key.setAttribute("keycode", toolDefinition.key); + } else { + key.setAttribute("key", toolDefinition.key); + } + + key.setAttribute("modifiers", toolDefinition.modifiers); + // needed. See bug 371900 + key.setAttribute("oncommand", "void(0);"); + key.addEventListener("command", () => { + this.selectTool(toolId).then(() => this.fireCustomKey(toolId)); + }, true); + doc.getElementById("toolbox-keyset").appendChild(key); + } + + // Add key for toggling the browser console from the detached window + if (!doc.getElementById("key_browserconsole")) { + let key = doc.createElement("key"); + key.id = "key_browserconsole"; + + key.setAttribute("key", L10N.getStr("browserConsoleCmd.commandkey")); + key.setAttribute("modifiers", "accel,shift"); + // needed. See bug 371900 + key.setAttribute("oncommand", "void(0)"); + key.addEventListener("command", () => { + HUDService.toggleBrowserConsole(); + }, true); + doc.getElementById("toolbox-keyset").appendChild(key); + } + }, + + /** + * 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: function (toolId) { + let 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/notification-box"); + + NotificationBox = this.React.createFactory(NotificationBox); + + // Render NotificationBox and assign priority levels to it. + let box = this.doc.getElementById("toolbox-notificationbox"); + this._notificationBox = Object.assign( + this.ReactDOM.render(NotificationBox({}), box), + PriorityLevels); + } + return this._notificationBox; + }, + + /** + * Build the buttons for changing hosts. Called every time + * the host changes. + */ + _buildDockButtons: function () { + let dockBox = this.doc.getElementById("toolbox-dock-buttons"); + + while (dockBox.firstChild) { + dockBox.removeChild(dockBox.firstChild); + } + + if (!this._target.isLocalTab) { + return; + } + + // Bottom-type host can be minimized, add a button for this. + if (this.hostType == Toolbox.HostType.BOTTOM) { + let minimizeBtn = this.doc.createElementNS(HTML_NS, "button"); + minimizeBtn.id = "toolbox-dock-bottom-minimize"; + minimizeBtn.className = "devtools-button"; + /* Bug 1177463 - The minimize button is currently hidden until we agree on + the UI for it, and until bug 1173849 is fixed too. */ + minimizeBtn.setAttribute("hidden", "true"); + + minimizeBtn.addEventListener("click", this._toggleMinimizeMode); + dockBox.appendChild(minimizeBtn); + // Show the button in its maximized state. + this._onBottomHostMaximized(); + + // Maximize again when a tool gets selected. + this.on("before-select", this._onToolSelectWhileMinimized); + // Maximize and stop listening before the host type changes. + this.once("host-will-change", this._onBottomHostWillChange); + } + + if (this.hostType == Toolbox.HostType.WINDOW) { + this.closeButton.setAttribute("hidden", "true"); + } else { + this.closeButton.removeAttribute("hidden"); + } + + let sideEnabled = Services.prefs.getBoolPref(this._prefs.SIDE_ENABLED); + + for (let type in Toolbox.HostType) { + let position = Toolbox.HostType[type]; + if (position == this.hostType || + position == Toolbox.HostType.CUSTOM || + (!sideEnabled && position == Toolbox.HostType.SIDE)) { + continue; + } + + let button = this.doc.createElementNS(HTML_NS, "button"); + button.id = "toolbox-dock-" + position; + button.className = "toolbox-dock-button devtools-button"; + button.setAttribute("title", L10N.getStr("toolboxDockButtons." + + position + ".tooltip")); + button.addEventListener("click", this.switchHost.bind(this, position)); + + dockBox.appendChild(button); + } + }, + + _getMinimizeButtonShortcutTooltip: function () { + let str = L10N.getStr("toolbox.minimize.key"); + let key = KeyShortcuts.parseElectronKey(this.win, str); + return "(" + KeyShortcuts.stringify(key) + ")"; + }, + + _onBottomHostMinimized: function () { + let btn = this.doc.querySelector("#toolbox-dock-bottom-minimize"); + btn.className = "minimized"; + + btn.setAttribute("title", + L10N.getStr("toolboxDockButtons.bottom.maximize") + " " + + this._getMinimizeButtonShortcutTooltip()); + }, + + _onBottomHostMaximized: function () { + let btn = this.doc.querySelector("#toolbox-dock-bottom-minimize"); + btn.className = "maximized"; + + btn.setAttribute("title", + L10N.getStr("toolboxDockButtons.bottom.minimize") + " " + + this._getMinimizeButtonShortcutTooltip()); + }, + + _onToolSelectWhileMinimized: function () { + this.postMessage({ + name: "maximize-host" + }); + }, + + postMessage: function (msg) { + // We sometime try to send messages in middle of destroy(), where the + // toolbox iframe may already be detached and no longer have a parent. + if (this.win.parent) { + // 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.win.parent.postMessage(msg, "*"); + } + }, + + _onBottomHostWillChange: function () { + this.postMessage({ + name: "maximize-host" + }); + + this.off("before-select", this._onToolSelectWhileMinimized); + }, + + _toggleMinimizeMode: function () { + if (this.hostType !== Toolbox.HostType.BOTTOM) { + return; + } + + // Calculate the height to which the host should be minimized so the + // tabbar is still visible. + let toolbarHeight = this.tabbar.getBoxQuads({box: "content"})[0].bounds + .height; + this.postMessage({ + name: "toggle-minimize-mode", + toolbarHeight + }); + }, + + /** + * Add tabs to the toolbox UI for registered tools + */ + _buildTabs: function () { + for (let definition of gDevTools.getToolDefinitionArray()) { + this._buildTabForTool(definition); + } + }, + + /** + * Get all dev tools tab bar focusable elements. These are visible elements + * such as buttons or elements with tabindex. + */ + get tabbarFocusableElms() { + return [...this.tabbar.querySelectorAll( + "[tabindex]:not([hidden]), button:not([hidden])")]; + }, + + /** + * Reset tabindex attributes across all focusable elements inside the tabbar. + * Only have one element with tabindex=0 at a time to make sure that tabbing + * results in navigating away from the tabbar container. + * @param {FocusEvent} event + */ + _onTabbarFocus: function (event) { + this.tabbarFocusableElms.forEach(elm => + elm.setAttribute("tabindex", event.target === elm ? "0" : "-1")); + }, + + /** + * On left/right arrow press, attempt to move the focus inside the tabbar to + * the previous/next focusable element. + * @param {KeyboardEvent} event + */ + _onTabbarArrowKeypress: function (event) { + let { 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; + } + + let focusableElms = this.tabbarFocusableElms; + let curIndex = focusableElms.indexOf(target); + + if (curIndex === -1) { + console.warn(target + " is not found among Developer Tools tab bar " + + "focusable elements. It needs to either be a button or have " + + "tabindex. If it is intended to be hidden, 'hidden' attribute must " + + "be used."); + return; + } + + let newTarget; + + if (key === "ArrowLeft") { + // Do nothing if already at the beginning. + if (curIndex === 0) { + return; + } + newTarget = focusableElms[curIndex - 1]; + } else if (key === "ArrowRight") { + // Do nothing if already at the end. + if (curIndex === focusableElms.length - 1) { + return; + } + newTarget = focusableElms[curIndex + 1]; + } else { + return; + } + + focusableElms.forEach(elm => + elm.setAttribute("tabindex", newTarget === elm ? "0" : "-1")); + newTarget.focus(); + + event.preventDefault(); + event.stopPropagation(); + }, + + /** + * Add buttons to the UI as specified in the devtools.toolbox.toolbarSpec pref + */ + _buildButtons: function () { + if (this.target.getTrait("highlightable")) { + this._buildPickerButton(); + } + + this.setToolboxButtonsVisibility(); + + // Old servers don't have a GCLI Actor, so just return + if (!this.target.hasActor("gcli")) { + return promise.resolve(); + } + // Disable gcli in browser toolbox until there is usages of it + if (this.target.chrome) { + return promise.resolve(); + } + + const options = { + environment: CommandUtils.createEnvironment(this, "_target") + }; + return CommandUtils.createRequisition(this.target, options).then(requisition => { + this._requisition = requisition; + + const spec = CommandUtils.getCommandbarSpec("devtools.toolbox.toolbarSpec"); + return CommandUtils.createButtons(spec, this.target, this.doc, requisition) + .then(buttons => { + let container = this.doc.getElementById("toolbox-buttons"); + buttons.forEach(button => { + if (button) { + container.appendChild(button); + } + }); + this.setToolboxButtonsVisibility(); + }); + }); + }, + + /** + * Adding the element picker button is done here unlike the other buttons + * since we want it to work for remote targets too + */ + _buildPickerButton: function () { + this._pickerButton = this.doc.createElementNS(HTML_NS, "button"); + this._pickerButton.id = "command-button-pick"; + this._pickerButton.className = + "command-button command-button-invertable devtools-button"; + this._pickerButton.setAttribute("title", L10N.getStr("pickButton.tooltip")); + + let container = this.doc.querySelector("#toolbox-picker-container"); + container.appendChild(this._pickerButton); + + this._pickerButton.addEventListener("click", this._onPickerClick, false); + }, + + /** + * 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. + */ + _onPickerClick: function () { + let focus = this.hostType === Toolbox.HostType.BOTTOM || + this.hostType === Toolbox.HostType.SIDE; + this.highlighterUtils.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: function (event) { + if (event.keyCode === KeyCodes.DOM_VK_ESCAPE) { + this.highlighterUtils.cancelPicker(); + // Stop the console from toggling. + event.stopImmediatePropagation(); + } + }, + + _onPickerStarted: function () { + this.doc.addEventListener("keypress", this._onPickerKeypress, true); + }, + + _onPickerStopped: function () { + this.doc.removeEventListener("keypress", this._onPickerKeypress, true); + }, + + /** + * Apply the current cache setting from devtools.cache.disabled to this + * toolbox's tab. + */ + _applyCacheSettings: function () { + let pref = "devtools.cache.disabled"; + let cacheDisabled = Services.prefs.getBoolPref(pref); + + if (this.target.activeTab) { + this.target.activeTab.reconfigure({"cacheDisabled": cacheDisabled}); + } + }, + + /** + * Apply the current service workers testing setting from + * devtools.serviceWorkers.testing.enabled to this toolbox's tab. + */ + _applyServiceWorkersTestingSettings: function () { + let pref = "devtools.serviceWorkers.testing.enabled"; + let serviceWorkersTestingEnabled = + Services.prefs.getBoolPref(pref) || false; + + if (this.target.activeTab) { + this.target.activeTab.reconfigure({ + "serviceWorkersTestingEnabled": serviceWorkersTestingEnabled + }); + } + }, + + /** + * Setter for the checked state of the picker button in the toolbar + * @param {Boolean} isChecked + */ + set pickerButtonChecked(isChecked) { + if (isChecked) { + this._pickerButton.setAttribute("checked", "true"); + } else { + this._pickerButton.removeAttribute("checked"); + } + }, + + /** + * Return all toolbox buttons (command buttons, plus any others that were + * added manually). + */ + get toolboxButtons() { + return ToolboxButtons.map(options => { + let button = this.doc.getElementById(options.id); + // Some buttons may not exist inside of Browser Toolbox + if (!button) { + return false; + } + + return { + id: options.id, + button: button, + label: button.getAttribute("title"), + visibilityswitch: "devtools." + options.id + ".enabled", + isTargetSupported: options.isTargetSupported + ? options.isTargetSupported + : target => target.isLocalTab, + }; + }).filter(button=>button); + }, + + /** + * Ensure the visibility of each toolbox button matches the + * preference value. Simply hide buttons that are preffed off. + */ + setToolboxButtonsVisibility: function () { + this.toolboxButtons.forEach(buttonSpec => { + let { visibilityswitch, button, isTargetSupported } = buttonSpec; + let on = true; + try { + on = Services.prefs.getBoolPref(visibilityswitch); + } catch (ex) { + // Do nothing. + } + + on = on && isTargetSupported(this.target); + + if (button) { + if (on) { + button.removeAttribute("hidden"); + } else { + button.setAttribute("hidden", "true"); + } + } + }); + + this._updateNoautohideButton(); + }, + + /** + * Build a tab for one tool definition and add to the toolbox + * + * @param {string} toolDefinition + * Tool definition of the tool to build a tab for. + */ + _buildTabForTool: function (toolDefinition) { + if (!toolDefinition.isTargetSupported(this._target)) { + return; + } + + let tabs = this.doc.getElementById("toolbox-tabs"); + let deck = this.doc.getElementById("toolbox-deck"); + + let id = toolDefinition.id; + + if (toolDefinition.ordinal == undefined || toolDefinition.ordinal < 0) { + toolDefinition.ordinal = MAX_ORDINAL; + } + + let radio = this.doc.createElement("radio"); + // The radio element is not being used in the conventional way, thus + // the devtools-tab class replaces the radio XBL binding with its base + // binding (the control-item binding). + radio.className = "devtools-tab"; + radio.id = "toolbox-tab-" + id; + radio.setAttribute("toolid", id); + radio.setAttribute("tabindex", "0"); + radio.setAttribute("ordinal", toolDefinition.ordinal); + radio.setAttribute("tooltiptext", toolDefinition.tooltip); + if (toolDefinition.invertIconForLightTheme) { + radio.setAttribute("icon-invertable", "light-theme"); + } else if (toolDefinition.invertIconForDarkTheme) { + radio.setAttribute("icon-invertable", "dark-theme"); + } + + radio.addEventListener("command", this.selectTool.bind(this, id)); + + // spacer lets us center the image and label, while allowing cropping + let spacer = this.doc.createElement("spacer"); + spacer.setAttribute("flex", "1"); + radio.appendChild(spacer); + + if (toolDefinition.icon) { + let image = this.doc.createElement("image"); + image.className = "default-icon"; + image.setAttribute("src", + toolDefinition.icon || toolDefinition.highlightedicon); + radio.appendChild(image); + // Adding the highlighted icon image + image = this.doc.createElement("image"); + image.className = "highlighted-icon"; + image.setAttribute("src", + toolDefinition.highlightedicon || toolDefinition.icon); + radio.appendChild(image); + } + + if (toolDefinition.label && !toolDefinition.iconOnly) { + let label = this.doc.createElement("label"); + label.setAttribute("value", toolDefinition.label); + label.setAttribute("crop", "end"); + label.setAttribute("flex", "1"); + radio.appendChild(label); + } + + if (!toolDefinition.bgTheme) { + toolDefinition.bgTheme = "theme-toolbar"; + } + let vbox = this.doc.createElement("vbox"); + vbox.className = "toolbox-panel " + toolDefinition.bgTheme; + + // There is already a container for the webconsole frame. + if (!this.doc.getElementById("toolbox-panel-" + id)) { + vbox.id = "toolbox-panel-" + id; + } + + if (id === "options") { + // Options panel is special. It doesn't belong in the same container as + // the other tabs. + radio.setAttribute("role", "button"); + let optionTabContainer = this.doc.getElementById("toolbox-option-container"); + optionTabContainer.appendChild(radio); + deck.appendChild(vbox); + } else { + radio.setAttribute("role", "tab"); + + // If there is no tab yet, or the ordinal to be added is the largest one. + if (tabs.childNodes.length == 0 || + tabs.lastChild.getAttribute("ordinal") <= toolDefinition.ordinal) { + tabs.appendChild(radio); + deck.appendChild(vbox); + } else { + // else, iterate over all the tabs to get the correct location. + Array.some(tabs.childNodes, (node, i) => { + if (+node.getAttribute("ordinal") > toolDefinition.ordinal) { + tabs.insertBefore(radio, node); + deck.insertBefore(vbox, deck.childNodes[i]); + return true; + } + return false; + }); + } + } + + this._addKeysToWindow(); + }, + + /** + * Ensure the tool with the given id is loaded. + * + * @param {string} id + * The id of the tool to load. + */ + loadTool: function (id) { + if (id === "inspector" && !this._inspector) { + return this.initInspector().then(() => { + return this.loadTool(id); + }); + } + + let deferred = defer(); + let iframe = this.doc.getElementById("toolbox-panel-iframe-" + id); + + if (iframe) { + let panel = this._toolPanels.get(id); + if (panel) { + deferred.resolve(panel); + } else { + this.once(id + "-ready", initializedPanel => { + deferred.resolve(initializedPanel); + }); + } + return deferred.promise; + } + + let definition = gDevTools.getToolDefinition(id); + if (!definition) { + deferred.reject(new Error("no such tool id " + id)); + return deferred.promise; + } + + iframe = this.doc.createElement("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) { + let vbox = this.doc.getElementById("toolbox-panel-" + id); + vbox.appendChild(iframe); + } + + let onLoad = () => { + // 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); + + if (!(typeof built.then == "function")) { + let 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(); + } else { + let buildDeferred = defer(); + buildDeferred.resolve(panel); + built = buildDeferred.promise; + } + } + + // 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); + + deferred.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) { + let domHelper = new DOMHelpers(iframe.contentWindow); + domHelper.onceDOMReady(onLoad); + } else { + let callback = () => { + iframe.removeEventListener("DOMContentLoaded", callback); + onLoad(); + }; + + iframe.addEventListener("DOMContentLoaded", callback); + } + + return deferred.promise; + }, + + /** + * Set the dir attribute on the content document element of the provided iframe. + * + * @param {IFrameElement} iframe + */ + setIframeDocumentDir: function (iframe) { + let docEl = iframe.contentWindow && 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. + let top = this.win.top; + let topDocEl = top.document.documentElement; + let isRtl = top.getComputedStyle(topDocEl).direction === "rtl"; + docEl.setAttribute("dir", isRtl ? "rtl" : "ltr"); + } + }, + + /** + * 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: function (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"); + } + }); + }, + + /** + * Switch to the tool with the given id + * + * @param {string} id + * The id of the tool to switch to + */ + selectTool: function (id) { + this.emit("before-select", id); + + let tabs = this.doc.querySelectorAll(".devtools-tab"); + this.selectSingleNode(tabs, "toolbox-tab-" + id); + + // If options is selected, the separator between it and the + // command buttons should be hidden. + let sep = this.doc.getElementById("toolbox-controls-separator"); + if (id === "options") { + sep.setAttribute("invisible", "true"); + } else { + sep.removeAttribute("invisible"); + } + + if (this.currentToolId == id) { + let 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"); + } + + let tab = this.doc.getElementById("toolbox-tab-" + id); + + if (tab) { + if (this.currentToolId) { + this._telemetry.toolClosed(this.currentToolId); + } + this._telemetry.toolOpened(id); + } else { + throw new Error("No tool found"); + } + + let tabstrip = this.doc.getElementById("toolbox-tabs"); + + // select the right tab, making 0th index the default tab if right tab not + // found. + tabstrip.selectedItem = tab || tabstrip.childNodes[0]; + + // and select the right iframe + let 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).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; + }); + }, + + /** + * Focus a tool's panel by id + * @param {string} id + * The id of tool to focus + */ + focusTool: function (id, state = true) { + let iframe = this.doc.getElementById("toolbox-panel-iframe-" + id); + + if (state) { + iframe.focus(); + } else { + iframe.blur(); + } + }, + + /** + * Focus split console's input line + */ + focusConsoleInput: function () { + let consolePanel = this.getPanel("webconsole"); + if (consolePanel) { + consolePanel.focusInput(); + } + }, + + /** + * 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. + */ + _onFocus: function ({originalTarget}) { + // Ignore any non element nodes, or any elements contained + // within the webconsole frame. + let webconsoleURL = gDevTools.getToolDefinition("webconsole").url; + if (originalTarget.nodeType !== 1 || + originalTarget.baseURI === webconsoleURL) { + return; + } + + this._lastFocusedElement = originalTarget; + }, + + /** + * Opens the split console. + * + * @returns {Promise} a promise that resolves once the tool has been + * loaded and focused. + */ + openSplitConsole: function () { + this._splitConsole = true; + Services.prefs.setBoolPref(SPLITCONSOLE_ENABLED_PREF, true); + this._refreshConsoleDisplay(); + this.emit("split-console"); + + return this.loadTool("webconsole").then(() => { + this.focusConsoleInput(); + }); + }, + + /** + * Closes the split console. + * + * @returns {Promise} a promise that resolves once the tool has been + * closed. + */ + closeSplitConsole: function () { + this._splitConsole = false; + Services.prefs.setBoolPref(SPLITCONSOLE_ENABLED_PREF, false); + this._refreshConsoleDisplay(); + 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: function () { + if (this.currentToolId !== "webconsole") { + return this.splitConsole ? + this.closeSplitConsole() : + this.openSplitConsole(); + } + + return promise.resolve(); + }, + + /** + * Tells the target tab to reload. + */ + reloadTarget: function (force) { + this.target.activeTab.reload({ force: force }); + }, + + /** + * Loads the tool next to the currently selected tool. + */ + selectNextTool: function () { + let tools = this.doc.querySelectorAll(".devtools-tab"); + let selected = this.doc.querySelector(".devtools-tab[selected]"); + let nextIndex = [...tools].indexOf(selected) + 1; + let next = tools[nextIndex] || tools[0]; + let tool = next.getAttribute("toolid"); + return this.selectTool(tool); + }, + + /** + * Loads the tool just left to the currently selected tool. + */ + selectPreviousTool: function () { + let tools = this.doc.querySelectorAll(".devtools-tab"); + let selected = this.doc.querySelector(".devtools-tab[selected]"); + let prevIndex = [...tools].indexOf(selected) - 1; + let prev = tools[prevIndex] || tools[tools.length - 1]; + let tool = prev.getAttribute("toolid"); + return this.selectTool(tool); + }, + + /** + * Highlights the tool's tab if it is not the currently selected tool. + * + * @param {string} id + * The id of the tool to highlight + */ + highlightTool: function (id) { + let tab = this.doc.getElementById("toolbox-tab-" + id); + tab && tab.setAttribute("highlighted", "true"); + }, + + /** + * De-highlights the tool's tab. + * + * @param {string} id + * The id of the tool to unhighlight + */ + unhighlightTool: function (id) { + let tab = this.doc.getElementById("toolbox-tab-" + id); + tab && tab.removeAttribute("highlighted"); + }, + + /** + * Raise the toolbox host. + */ + raise: function () { + this.postMessage({ + name: "raise-host" + }); + }, + + /** + * Refresh the host's title. + */ + _refreshHostTitle: function () { + let title; + if (this.target.name && this.target.name != this.target.url) { + title = L10N.getFormatStr("toolbox.titleTemplate2", this.target.name, + this.target.url); + } else { + title = L10N.getFormatStr("toolbox.titleTemplate1", this.target.url); + } + this.postMessage({ + name: "set-host-title", + title + }); + }, + + // Returns an instance of the preference actor + get _preferenceFront() { + return this.target.root.then(rootForm => { + return getPreferenceFront(this.target.client, rootForm); + }); + }, + + _toggleAutohide: Task.async(function* () { + let prefName = "ui.popup.disable_autohide"; + let front = yield this._preferenceFront; + let current = yield front.getBoolPref(prefName); + yield front.setBoolPref(prefName, !current); + + this._updateNoautohideButton(); + }), + + _updateNoautohideButton: Task.async(function* () { + let menu = this.doc.getElementById("command-button-noautohide"); + if (menu.getAttribute("hidden") === "true") { + return; + } + if (!this.target.root) { + return; + } + let prefName = "ui.popup.disable_autohide"; + let front = yield this._preferenceFront; + let current = yield front.getBoolPref(prefName); + if (current) { + menu.setAttribute("checked", "true"); + } else { + menu.removeAttribute("checked"); + } + }), + + _listFrames: function (event) { + if (!this._target.activeTab || !this._target.activeTab.traits.frames) { + // We are not targetting a regular TabActor + // it can be either an addon or browser toolbox actor + return promise.resolve(); + } + let packet = { + to: this._target.form.actor, + type: "listFrames" + }; + return this._target.client.request(packet, resp => { + this._updateFrames(null, { frames: resp.frames }); + }); + }, + + /** + * Show a drop down menu that allows the user to switch frames. + */ + showFramesMenu: function (event) { + let menu = new Menu(); + let target = event.target; + + // Generate list of menu items from the list of frames. + this.frameMap.forEach(frame => { + // A frame is checked if it's the selected one. + let checked = frame.id == this.selectedFrameId; + + // Create menu item. + menu.append(new MenuItem({ + label: frame.url, + type: "radio", + checked, + click: () => { + this.onSelectFrame(frame.id); + } + })); + }); + + menu.once("open").then(() => { + target.setAttribute("open", "true"); + }); + + menu.once("close").then(() => { + target.removeAttribute("open"); + }); + + // Show a drop down menu with frames. + // XXX Missing menu API for specifying target (anchor) + // and relative position to it. See also: + // https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/Method/openPopup + // https://bugzilla.mozilla.org/show_bug.cgi?id=1274551 + let rect = target.getBoundingClientRect(); + let screenX = target.ownerDocument.defaultView.mozInnerScreenX; + let screenY = target.ownerDocument.defaultView.mozInnerScreenY; + menu.popup(rect.left + screenX, rect.bottom + screenY, this); + + return menu; + }, + + /** + * Select a frame by sending 'switchToFrame' packet to the backend. + */ + onSelectFrame: function (frameId) { + // Send packet to the backend to select specified frame and + // wait for 'frameUpdate' event packet to update the UI. + let packet = { + to: this._target.form.actor, + type: "switchToFrame", + windowId: frameId + }; + this._target.client.request(packet); + }, + + /** + * A handler for 'frameUpdate' packets received from the backend. + * Following properties might be set on the packet: + * + * destroyAll {Boolean}: All frames have been destroyed. + * selected {Number}: A frame has been selected + * frames {Array}: list of frames. Every frame can have: + * id {Number}: frame ID + * url {String}: frame URL + * title {String}: frame title + * destroy {Boolean}: Set to true if destroyed + * parentID {Number}: ID of the parent frame (not set + * for top level window) + */ + _updateFrames: function (event, data) { + if (!Services.prefs.getBoolPref("devtools.command-button-frames.enabled")) { + return; + } + + // We may receive this event before the toolbox is ready. + if (!this.isReady) { + return; + } + + // Store (synchronize) data about all existing frames on the backend + if (data.destroyAll) { + this.frameMap.clear(); + this.selectedFrameId = null; + } else if (data.selected) { + this.selectedFrameId = data.selected; + } 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) { + let frames = [...this.frameMap.values()]; + let topFrames = frames.filter(frame => !frame.parentID); + this.selectedFrameId = topFrames.length ? topFrames[0].id : null; + } + + // Check out whether top frame is currently selected. + // Note that only child frame has parentID. + let frame = this.frameMap.get(this.selectedFrameId); + let topFrameSelected = frame ? !frame.parentID : false; + let button = this.doc.getElementById("command-button-frames"); + button.removeAttribute("checked"); + + // If non-top level frame is selected the toolbar button is + // marked as 'checked' indicating that a child frame is active. + if (!topFrameSelected && this.selectedFrameId) { + button.setAttribute("checked", "true"); + } + }, + + /** + * Switch to the last used host for the toolbox UI. + */ + switchToPreviousHost: function () { + 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: function (hostType) { + if (hostType == this.hostType || !this._target.isLocalTab) { + return null; + } + + 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"); + }, + + _onSwitchedHost: function ({ hostType }) { + this._hostType = hostType; + + this._buildDockButtons(); + this._addKeysToWindow(); + + // 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.log(HOST_HISTOGRAM, this._getTelemetryHostId()); + }, + + /** + * Return if the tool is available as a tab (i.e. if it's checked + * in the options panel). This is different from Toolbox.getPanel - + * a tool could be registered but not yet opened in which case + * isToolRegistered would return true but getPanel would return false. + */ + isToolRegistered: function (toolId) { + return gDevTools.getToolDefinitionMap().has(toolId); + }, + + /** + * Handler for the tool-registered event. + * @param {string} event + * Name of the event ("tool-registered") + * @param {string} toolId + * Id of the tool that was registered + */ + _toolRegistered: function (event, toolId) { + let tool = gDevTools.getToolDefinition(toolId); + this._buildTabForTool(tool); + // 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} event + * Name of the event ("tool-unregistered") + * @param {string|object} toolId + * Definition or id of the tool that was unregistered. Passing the + * tool id should be avoided as it is a temporary measure. + */ + _toolUnregistered: function (event, toolId) { + if (typeof toolId != "string") { + toolId = toolId.id; + } + + if (this._toolPanels.has(toolId)) { + let instance = this._toolPanels.get(toolId); + instance.destroy(); + this._toolPanels.delete(toolId); + } + + let radio = this.doc.getElementById("toolbox-tab-" + toolId); + let panel = this.doc.getElementById("toolbox-panel-" + toolId); + + if (radio) { + if (this.currentToolId == toolId) { + let nextToolName = null; + if (radio.nextSibling) { + nextToolName = radio.nextSibling.getAttribute("toolid"); + } + if (radio.previousSibling) { + nextToolName = radio.previousSibling.getAttribute("toolid"); + } + if (nextToolName) { + this.selectTool(nextToolName); + } + } + radio.parentNode.removeChild(radio); + } + + if (panel) { + panel.parentNode.removeChild(panel); + } + + if (this.hostType == Toolbox.HostType.WINDOW) { + let doc = this.win.parent.document; + let key = doc.getElementById("key_" + toolId); + if (key) { + key.parentNode.removeChild(key); + } + } + // Emit the event so tools can listen to it from the toolbox level + // instead of gDevTools + this.emit("tool-unregistered", toolId); + }, + + /** + * Initialize the inspector/walker/selection/highlighter fronts. + * Returns a promise that resolves when the fronts are initialized + */ + initInspector: function () { + if (!this._initInspector) { + this._initInspector = Task.spawn(function* () { + this._inspector = InspectorFront(this._target.client, this._target.form); + let pref = "devtools.inspector.showAllAnonymousContent"; + let showAllAnonymousContent = Services.prefs.getBoolPref(pref); + this._walker = yield this._inspector.getWalker({ showAllAnonymousContent }); + this._selection = new Selection(this._walker); + + if (this.highlighterUtils.isRemoteHighlightable()) { + this.walker.on("highlighter-ready", this._highlighterReady); + this.walker.on("highlighter-hide", this._highlighterHidden); + + let autohide = !flags.testing; + this._highlighter = yield this._inspector.getHighlighter(autohide); + } + }.bind(this)); + } + return this._initInspector; + }, + + /** + * Destroy the inspector/walker/selection fronts + * Returns a promise that resolves when the fronts are destroyed + */ + destroyInspector: function () { + if (this._destroyingInspector) { + return this._destroyingInspector; + } + + this._destroyingInspector = Task.spawn(function* () { + if (!this._inspector) { + return; + } + + // Releasing the walker (if it has been created) + // This can fail, but in any case, we want to continue destroying the + // inspector/highlighter/selection + // FF42+: Inspector actor starts managing Walker actor and auto destroy it. + if (this._walker && !this.walker.traits.autoReleased) { + try { + yield this._walker.release(); + } catch (e) { + // Do nothing; + } + } + + yield this.highlighterUtils.stopPicker(); + yield this._inspector.destroy(); + if (this._highlighter) { + // Note that if the toolbox is closed, this will work fine, but will fail + // in case the browser is closed and will trigger a noSuchActor message. + // We ignore the promise that |_hideBoxModel| returns, since we should still + // proceed with the rest of destruction if it fails. + // FF42+ now does the cleanup from the actor. + if (!this.highlighter.traits.autoHideOnDestroy) { + this.highlighterUtils.unhighlight(); + } + yield this._highlighter.destroy(); + } + if (this._selection) { + this._selection.destroy(); + } + + if (this.walker) { + this.walker.off("highlighter-ready", this._highlighterReady); + this.walker.off("highlighter-hide", this._highlighterHidden); + } + + this._inspector = null; + this._highlighter = null; + this._selection = null; + this._walker = null; + }.bind(this)); + return this._destroyingInspector; + }, + + /** + * Get the toolbox's notification component + * + * @return The notification box component. + */ + getNotificationBox: function () { + return this.notificationBox; + }, + + /** + * Remove all UI elements, detach from target and clear up + */ + destroy: function () { + // 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; + } + let deferred = defer(); + this._destroyer = deferred.promise; + + this.emit("destroy"); + + this._target.off("navigate", this._refreshHostTitle); + this._target.off("frame-update", this._updateFrames); + this.off("select", this._refreshHostTitle); + this.off("host-changed", this._refreshHostTitle); + this.off("ready", this._showDevEditionPromo); + + gDevTools.off("tool-registered", this._toolRegistered); + gDevTools.off("tool-unregistered", this._toolUnregistered); + + gDevTools.off("pref-changed", this._prefChanged); + + this._lastFocusedElement = null; + if (this._sourceMapService) { + this._sourceMapService.destroy(); + this._sourceMapService = null; + } + + if (this.webconsolePanel) { + this._saveSplitConsoleHeight(); + this.webconsolePanel.removeEventListener("resize", + this._saveSplitConsoleHeight); + this.webconsolePanel = null; + } + if (this.closeButton) { + this.closeButton.removeEventListener("click", this.destroy, true); + this.closeButton = null; + } + if (this.textBoxContextMenuPopup) { + this.textBoxContextMenuPopup.removeEventListener("popupshowing", + this._updateTextBoxMenuItems, true); + this.textBoxContextMenuPopup = null; + } + if (this.tabbar) { + this.tabbar.removeEventListener("focus", this._onTabbarFocus, true); + this.tabbar.removeEventListener("click", this._onTabbarFocus, true); + this.tabbar.removeEventListener("keypress", this._onTabbarArrowKeypress); + this.tabbar = null; + } + + let outstanding = []; + for (let [id, panel] of this._toolPanels) { + try { + gDevTools.emit(id + "-destroy", this, panel); + this.emit(id + "-destroy", panel); + + outstanding.push(panel.destroy()); + } catch (e) { + // We don't want to stop here if any panel fail to close. + console.error("Panel " + id + ":", e); + } + } + + this.browserRequire = null; + + // Now that we are closing the toolbox we can re-enable the cache settings + // and disable the service workers testing settings for the current tab. + // FF41+ automatically cleans up state in actor on disconnect. + if (this.target.activeTab && !this.target.activeTab.traits.noTabReconfigureOnClose) { + this.target.activeTab.reconfigure({ + "cacheDisabled": false, + "serviceWorkersTestingEnabled": false + }); + } + + // Destroying the walker and inspector fronts + outstanding.push(this.destroyInspector().then(() => { + // Removing buttons + if (this._pickerButton) { + this._pickerButton.removeEventListener("click", this._togglePicker, false); + this._pickerButton = null; + } + })); + + // Destroy the profiler connection + outstanding.push(this.destroyPerformance()); + + // Detach the thread + detachThread(this._threadClient); + this._threadClient = null; + + // We need to grab a reference to win before this._host is destroyed. + let win = this.win; + + if (this._requisition) { + CommandUtils.destroyRequisition(this._requisition, this.target); + } + this._telemetry.toolClosed("toolbox"); + this._telemetry.destroy(); + + // Finish all outstanding tasks (which means finish destroying panels and + // then destroying the host, successfully or not) before destroying the + // target. + deferred.resolve(settleAll(outstanding) + .catch(console.error) + .then(() => { + this._removeHostListeners(); + + // `location` may already be null if the toolbox document is already + // in process of destruction. Otherwise if it is still around, ensure + // releasing toolbox document and triggering cleanup thanks to unload + // event. We do that precisely here, before nullifying the target as + // various cleanup code depends on the target attribute to be still + // defined. + if (win.location) { + win.location.replace("about:blank"); + } + + // Targets need to be notified that the toolbox is being torn down. + // 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. + if (!this._target) { + return null; + } + let target = this._target; + this._target = null; + this.highlighterUtils.release(); + target.off("close", this.destroy); + return target.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(); + + // 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.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .garbageCollect(); + } + }).then(null, console.error)); + + let leakCheckObserver = ({wrappedJSObject: barrier}) => { + // Make the leak detector wait until this toolbox is properly destroyed. + barrier.client.addBlocker("DevTools: Wait until toolbox is destroyed", + this._destroyer); + }; + + let topic = "shutdown-leaks-before-check"; + Services.obs.addObserver(leakCheckObserver, topic, false); + this._destroyer.then(() => { + Services.obs.removeObserver(leakCheckObserver, topic); + }); + + return this._destroyer; + }, + + _highlighterReady: function () { + this.emit("highlighter-ready"); + }, + + _highlighterHidden: function () { + this.emit("highlighter-hide"); + }, + + /** + * For displaying the promotional Doorhanger on first opening of + * the developer tools, promoting the Developer Edition. + */ + _showDevEditionPromo: function () { + // Do not display in browser toolbox + if (this.target.chrome) { + return; + } + showDoorhanger({ window: this.win, type: "deveditionpromo" }); + }, + + /** + * Enable / disable necessary textbox menu items using globalOverlay.js. + */ + _updateTextBoxMenuItems: function () { + let window = this.win; + ["cmd_undo", "cmd_delete", "cmd_cut", + "cmd_copy", "cmd_paste", "cmd_selectAll"].forEach(window.goUpdateCommand); + }, + + /** + * 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: function (x, y) { + this.textBoxContextMenuPopup.openPopupAtScreen(x, y, true); + }, + + /** + * Connects to the SPS profiler when the developer tools are open. This is + * necessary because of the WebConsole's `profile` and `profileEnd` methods. + */ + initPerformance: Task.async(function* () { + // If target does not have profiler actor (addons), do not + // even register the shared performance connection. + if (!this.target.hasActor("profiler")) { + return promise.resolve(); + } + + if (this._performanceFrontConnection) { + return this._performanceFrontConnection.promise; + } + + this._performanceFrontConnection = defer(); + this._performance = createPerformanceFront(this._target); + yield this.performance.connect(); + + // Emit an event when connected, but don't wait on startup for this. + this.emit("profiler-connected"); + + this.performance.on("*", this._onPerformanceFrontEvent); + this._performanceFrontConnection.resolve(this.performance); + return this._performanceFrontConnection.promise; + }), + + /** + * Disconnects the underlying Performance actor. If the connection + * has not finished initializing, as opening a toolbox does not wait, + * the performance connection destroy method will wait for it on its own. + */ + destroyPerformance: Task.async(function* () { + if (!this.performance) { + return; + } + // If still connecting to performance actor, allow the + // actor to resolve its connection before attempting to destroy. + if (this._performanceFrontConnection) { + yield this._performanceFrontConnection.promise; + } + this.performance.off("*", this._onPerformanceFrontEvent); + yield this.performance.destroy(); + this._performance = null; + }), + + /** + * Called when any event comes from the PerformanceFront. If the performance tool is + * already loaded when the first event comes in, immediately unbind this handler, as + * this is only used to queue up observed recordings before the performance tool can + * handle them, which will only occur when `console.profile()` recordings are started + * before the tool loads. + */ + _onPerformanceFrontEvent: Task.async(function* (eventName, recording) { + if (this.getPanel("performance")) { + this.performance.off("*", this._onPerformanceFrontEvent); + return; + } + + this._performanceQueuedRecordings = this._performanceQueuedRecordings || []; + let recordings = this._performanceQueuedRecordings; + + // Before any console recordings, we'll get a `console-profile-start` event + // warning us that a recording will come later (via `recording-started`), so + // start to boot up the tool and populate the tool with any other recordings + // observed during that time. + if (eventName === "console-profile-start" && !this._performanceToolOpenedViaConsole) { + this._performanceToolOpenedViaConsole = this.loadTool("performance"); + let panel = yield this._performanceToolOpenedViaConsole; + yield panel.open(); + + panel.panelWin.PerformanceController.populateWithRecordings(recordings); + this.performance.off("*", this._onPerformanceFrontEvent); + } + + // Otherwise, if it's a recording-started event, we've already started loading + // the tool, so just store this recording in our array to be later populated + // once the tool loads. + if (eventName === "recording-started") { + recordings.push(recording); + } + }), + + /** + * Returns gViewSourceUtils for viewing source. + */ + get gViewSourceUtils() { + return this.win.gViewSourceUtils; + }, + + /** + * Opens source in style editor. Falls back to plain "view-source:". + * @see devtools/client/shared/source-utils.js + */ + viewSourceInStyleEditor: function (sourceURL, sourceLine) { + return viewSource.viewSourceInStyleEditor(this, sourceURL, sourceLine); + }, + + /** + * Opens source in debugger. Falls back to plain "view-source:". + * @see devtools/client/shared/source-utils.js + */ + viewSourceInDebugger: function (sourceURL, sourceLine) { + return viewSource.viewSourceInDebugger(this, sourceURL, sourceLine); + }, + + /** + * Opens source in scratchpad. Falls back to plain "view-source:". + * TODO The `sourceURL` for scratchpad instances are like `Scratchpad/1`. + * If instances are scoped one-per-browser-window, then we should be able + * to infer the URL from this toolbox, or use the built in scratchpad IN + * the toolbox. + * + * @see devtools/client/shared/source-utils.js + */ + viewSourceInScratchpad: function (sourceURL, sourceLine) { + return viewSource.viewSourceInScratchpad(sourceURL, sourceLine); + }, + + /** + * Opens source in plain "view-source:". + * @see devtools/client/shared/source-utils.js + */ + viewSource: function (sourceURL, sourceLine) { + return viewSource.viewSource(this, sourceURL, sourceLine); + }, +}; diff --git a/devtools/client/framework/toolbox.xul b/devtools/client/framework/toolbox.xul new file mode 100644 index 000000000..94aaecebd --- /dev/null +++ b/devtools/client/framework/toolbox.xul @@ -0,0 +1,83 @@ +<?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/. --> +<?xml-stylesheet href="chrome://global/skin/" type="text/css"?> +<?xml-stylesheet href="chrome://devtools/skin/toolbox.css" type="text/css"?> +<?xml-stylesheet href="resource://devtools/client/shared/components/notification-box.css" type="text/css"?> + +<?xul-overlay href="chrome://global/content/editMenuOverlay.xul"?> + +<!DOCTYPE window [ +<!ENTITY % toolboxDTD SYSTEM "chrome://devtools/locale/toolbox.dtd" > +%toolboxDTD; +<!ENTITY % editMenuStrings SYSTEM "chrome://global/locale/editMenuOverlay.dtd"> +%editMenuStrings; +<!ENTITY % globalKeysDTD SYSTEM "chrome://global/locale/globalKeys.dtd"> +%globalKeysDTD; +]> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script type="application/javascript;version=1.8" + src="chrome://devtools/content/shared/theme-switching.js"/> + <script type="application/javascript" + src="chrome://global/content/viewSourceUtils.js"/> + + <script type="application/javascript" src="chrome://global/content/globalOverlay.js"/> + <script type="application/javascript;version=1.8" + src="chrome://devtools/content/framework/toolbox-init.js"/> + + <commandset id="editMenuCommands"/> + <keyset id="editMenuKeys"/> + + <popupset> + <menupopup id="toolbox-textbox-context-popup"> + <menuitem id="cMenu_undo"/> + <menuseparator/> + <menuitem id="cMenu_cut"/> + <menuitem id="cMenu_copy"/> + <menuitem id="cMenu_paste"/> + <menuitem id="cMenu_delete"/> + <menuseparator/> + <menuitem id="cMenu_selectAll"/> + </menupopup> + </popupset> + + <vbox id="toolbox-container" flex="1"> + <div xmlns="http://www.w3.org/1999/xhtml" id="toolbox-notificationbox"/> + <toolbar class="devtools-tabbar"> + <hbox id="toolbox-picker-container" /> + <hbox id="toolbox-tabs" flex="1" role="tablist" /> + <hbox id="toolbox-buttons" pack="end"> + <html:button id="command-button-frames" + class="command-button command-button-invertable devtools-button" + title="&toolboxFramesTooltip;" + hidden="true" /> + <html:button id="command-button-noautohide" + class="command-button command-button-invertable devtools-button" + title="&toolboxNoAutoHideTooltip;" + hidden="true" /> + </hbox> + <vbox id="toolbox-controls-separator" class="devtools-separator"/> + <hbox id="toolbox-option-container"/> + <hbox id="toolbox-controls"> + <hbox id="toolbox-dock-buttons"/> + <html:button id="toolbox-close" + class="devtools-button" + title="&toolboxCloseButton.tooltip;"/> + </hbox> + </toolbar> + <vbox flex="1" class="theme-body"> + <!-- Set large flex to allow the toolbox-panel-webconsole to have a + height set to a small value without flexing to fill up extra + space. There must be a flex on both to ensure that the console + panel itself is sized properly --> + <box id="toolbox-deck" flex="1000" minheight="75" /> + <splitter id="toolbox-console-splitter" class="devtools-horizontal-splitter" hidden="true" /> + <box minheight="75" flex="1" id="toolbox-panel-webconsole" collapsed="true" /> + </vbox> + <tooltip id="aHTMLTooltip" page="true" /> + </vbox> +</window> |