diff options
Diffstat (limited to 'devtools/server')
508 files changed, 80988 insertions, 0 deletions
diff --git a/devtools/server/actors/actor-registry.js b/devtools/server/actors/actor-registry.js new file mode 100644 index 000000000..6a083ba6f --- /dev/null +++ b/devtools/server/actors/actor-registry.js @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const protocol = require("devtools/shared/protocol"); +const { method, custom, Arg, Option, RetVal } = protocol; + +const { Cu, CC, components } = require("chrome"); +const Services = require("Services"); +const { DebuggerServer } = require("devtools/server/main"); +const { registerActor, unregisterActor } = require("devtools/server/actors/utils/actor-registry-utils"); +const { actorActorSpec, actorRegistrySpec } = require("devtools/shared/specs/actor-registry"); + +/** + * The ActorActor gives you a handle to an actor you've dynamically + * registered and allows you to unregister it. + */ +const ActorActor = protocol.ActorClassWithSpec(actorActorSpec, { + initialize: function (conn, options) { + protocol.Actor.prototype.initialize.call(this, conn); + + this.options = options; + }, + + unregister: function () { + unregisterActor(this.options); + } +}); + +/* + * The ActorRegistryActor allows clients to define new actors on the + * server. This is particularly useful for addons. + */ +const ActorRegistryActor = protocol.ActorClassWithSpec(actorRegistrySpec, { + initialize: function (conn) { + protocol.Actor.prototype.initialize.call(this, conn); + }, + + registerActor: function (sourceText, fileName, options) { + return registerActor(sourceText, fileName, options).then(() => { + let { constructor, type } = options; + + return ActorActor(this.conn, { + name: constructor, + tab: type.tab, + global: type.global + }); + }); + } +}); + +exports.ActorRegistryActor = ActorRegistryActor; diff --git a/devtools/server/actors/addon.js b/devtools/server/actors/addon.js new file mode 100644 index 000000000..7f152e984 --- /dev/null +++ b/devtools/server/actors/addon.js @@ -0,0 +1,352 @@ +/* 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 { Ci, Cu } = require("chrome"); +var Services = require("Services"); +var { ActorPool } = require("devtools/server/actors/common"); +var { TabSources } = require("./utils/TabSources"); +var makeDebugger = require("./utils/make-debugger"); +var { ConsoleAPIListener } = require("devtools/server/actors/utils/webconsole-utils"); +var DevToolsUtils = require("devtools/shared/DevToolsUtils"); +var { assert, update } = DevToolsUtils; + +loader.lazyRequireGetter(this, "AddonThreadActor", "devtools/server/actors/script", true); +loader.lazyRequireGetter(this, "unwrapDebuggerObjectGlobal", "devtools/server/actors/script", true); +loader.lazyRequireGetter(this, "mapURIToAddonID", "devtools/server/actors/utils/map-uri-to-addon-id"); +loader.lazyRequireGetter(this, "WebConsoleActor", "devtools/server/actors/webconsole", true); + +loader.lazyImporter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm"); + +function BrowserAddonActor(aConnection, aAddon) { + this.conn = aConnection; + this._addon = aAddon; + this._contextPool = new ActorPool(this.conn); + this.conn.addActorPool(this._contextPool); + this.threadActor = null; + this._global = null; + + this._shouldAddNewGlobalAsDebuggee = this._shouldAddNewGlobalAsDebuggee.bind(this); + + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: this._findDebuggees.bind(this), + shouldAddNewGlobalAsDebuggee: this._shouldAddNewGlobalAsDebuggee + }); + + AddonManager.addAddonListener(this); +} +exports.BrowserAddonActor = BrowserAddonActor; + +BrowserAddonActor.prototype = { + actorPrefix: "addon", + + get exited() { + return !this._addon; + }, + + get id() { + return this._addon.id; + }, + + get url() { + return this._addon.sourceURI ? this._addon.sourceURI.spec : undefined; + }, + + get attached() { + return this.threadActor; + }, + + get global() { + return this._global; + }, + + get sources() { + if (!this._sources) { + assert(this.threadActor, "threadActor should exist when creating sources."); + this._sources = new TabSources(this.threadActor, this._allowSource); + } + return this._sources; + }, + + + form: function BAA_form() { + assert(this.actorID, "addon should have an actorID."); + if (!this._consoleActor) { + this._consoleActor = new AddonConsoleActor(this._addon, this.conn, this); + this._contextPool.addActor(this._consoleActor); + } + + return { + actor: this.actorID, + id: this.id, + name: this._addon.name, + url: this.url, + iconURL: this._addon.iconURL, + debuggable: this._addon.isDebuggable, + temporarilyInstalled: this._addon.temporarilyInstalled, + consoleActor: this._consoleActor.actorID, + + traits: { + highlightable: false, + networkMonitor: false, + }, + }; + }, + + disconnect: function BAA_disconnect() { + this.conn.removeActorPool(this._contextPool); + this._contextPool = null; + this._consoleActor = null; + this._addon = null; + this._global = null; + AddonManager.removeAddonListener(this); + }, + + setOptions: function BAA_setOptions(aOptions) { + if ("global" in aOptions) { + this._global = aOptions.global; + } + }, + + onInstalled: function BAA_updateAddonWrapper(aAddon) { + if (aAddon.id != this._addon.id) { + return; + } + + // Update the AddonManager's addon object on reload/update. + this._addon = aAddon; + }, + + onDisabled: function BAA_onDisabled(aAddon) { + if (aAddon != this._addon) { + return; + } + + this._global = null; + }, + + onUninstalled: function BAA_onUninstalled(aAddon) { + if (aAddon != this._addon) { + return; + } + + if (this.attached) { + this.onDetach(); + + // The BrowserAddonActor is not a TabActor and it has to send + // "tabDetached" directly to close the devtools toolbox window. + this.conn.send({ from: this.actorID, type: "tabDetached" }); + } + + this.disconnect(); + }, + + onAttach: function BAA_onAttach() { + if (this.exited) { + return { type: "exited" }; + } + + if (!this.attached) { + this.threadActor = new AddonThreadActor(this.conn, this); + this._contextPool.addActor(this.threadActor); + } + + return { type: "tabAttached", threadActor: this.threadActor.actorID }; + }, + + onDetach: function BAA_onDetach() { + if (!this.attached) { + return { error: "wrongState" }; + } + + this._contextPool.removeActor(this.threadActor); + + this.threadActor = null; + this._sources = null; + + return { type: "detached" }; + }, + + onReload: function BAA_onReload() { + return this._addon.reload() + .then(() => { + return {}; // send an empty response + }); + }, + + preNest: function () { + let e = Services.wm.getEnumerator(null); + while (e.hasMoreElements()) { + let win = e.getNext(); + let windowUtils = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + windowUtils.suppressEventHandling(true); + windowUtils.suspendTimeouts(); + } + }, + + postNest: function () { + let e = Services.wm.getEnumerator(null); + while (e.hasMoreElements()) { + let win = e.getNext(); + let windowUtils = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + windowUtils.resumeTimeouts(); + windowUtils.suppressEventHandling(false); + } + }, + + /** + * Return true if the given global is associated with this addon and should be + * added as a debuggee, false otherwise. + */ + _shouldAddNewGlobalAsDebuggee: function (aGlobal) { + const global = unwrapDebuggerObjectGlobal(aGlobal); + try { + // This will fail for non-Sandbox objects, hence the try-catch block. + let metadata = Cu.getSandboxMetadata(global); + if (metadata) { + return metadata.addonID === this.id; + } + } catch (e) {} + + if (global instanceof Ci.nsIDOMWindow) { + return mapURIToAddonID(global.document.documentURIObject) == this.id; + } + + // Check the global for a __URI__ property and then try to map that to an + // add-on + let uridescriptor = aGlobal.getOwnPropertyDescriptor("__URI__"); + if (uridescriptor && "value" in uridescriptor && uridescriptor.value) { + let uri; + try { + uri = Services.io.newURI(uridescriptor.value, null, null); + } + catch (e) { + DevToolsUtils.reportException( + "BrowserAddonActor.prototype._shouldAddNewGlobalAsDebuggee", + new Error("Invalid URI: " + uridescriptor.value) + ); + return false; + } + + if (mapURIToAddonID(uri) == this.id) { + return true; + } + } + + return false; + }, + + /** + * Override the eligibility check for scripts and sources to make + * sure every script and source with a URL is stored when debugging + * add-ons. + */ + _allowSource: function (aSource) { + // XPIProvider.jsm evals some code in every add-on's bootstrap.js. Hide it. + if (aSource.url === "resource://gre/modules/addons/XPIProvider.jsm") { + return false; + } + + return true; + }, + + /** + * Yield the current set of globals associated with this addon that should be + * added as debuggees. + */ + _findDebuggees: function (dbg) { + return dbg.findAllGlobals().filter(this._shouldAddNewGlobalAsDebuggee); + } +}; + +BrowserAddonActor.prototype.requestTypes = { + "attach": BrowserAddonActor.prototype.onAttach, + "detach": BrowserAddonActor.prototype.onDetach, + "reload": BrowserAddonActor.prototype.onReload +}; + +/** + * The AddonConsoleActor implements capabilities needed for the add-on web + * console feature. + * + * @constructor + * @param object aAddon + * The add-on that this console watches. + * @param object aConnection + * The connection to the client, DebuggerServerConnection. + * @param object aParentActor + * The parent BrowserAddonActor actor. + */ +function AddonConsoleActor(aAddon, aConnection, aParentActor) +{ + this.addon = aAddon; + WebConsoleActor.call(this, aConnection, aParentActor); +} + +AddonConsoleActor.prototype = Object.create(WebConsoleActor.prototype); + +update(AddonConsoleActor.prototype, { + constructor: AddonConsoleActor, + + actorPrefix: "addonConsole", + + /** + * The add-on that this console watches. + */ + addon: null, + + /** + * The main add-on JS global + */ + get window() { + return this.parentActor.global; + }, + + /** + * Destroy the current AddonConsoleActor instance. + */ + disconnect: function ACA_disconnect() + { + WebConsoleActor.prototype.disconnect.call(this); + this.addon = null; + }, + + /** + * Handler for the "startListeners" request. + * + * @param object aRequest + * The JSON request object received from the Web Console client. + * @return object + * The response object which holds the startedListeners array. + */ + onStartListeners: function ACA_onStartListeners(aRequest) + { + let startedListeners = []; + + while (aRequest.listeners.length > 0) { + let listener = aRequest.listeners.shift(); + switch (listener) { + case "ConsoleAPI": + if (!this.consoleAPIListener) { + this.consoleAPIListener = + new ConsoleAPIListener(null, this, { addonId: this.addon.id }); + this.consoleAPIListener.init(); + } + startedListeners.push(listener); + break; + } + } + return { + startedListeners: startedListeners, + nativeConsoleAPI: true, + traits: this.traits, + }; + }, +}); + +AddonConsoleActor.prototype.requestTypes = Object.create(WebConsoleActor.prototype.requestTypes); +AddonConsoleActor.prototype.requestTypes.startListeners = AddonConsoleActor.prototype.onStartListeners; diff --git a/devtools/server/actors/addons.js b/devtools/server/actors/addons.js new file mode 100644 index 000000000..297a3a438 --- /dev/null +++ b/devtools/server/actors/addons.js @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const {AddonManager} = require("resource://gre/modules/AddonManager.jsm"); +const protocol = require("devtools/shared/protocol"); +const {FileUtils} = require("resource://gre/modules/FileUtils.jsm"); +const {Task} = require("devtools/shared/task"); +const {addonsSpec} = require("devtools/shared/specs/addons"); + +const AddonsActor = protocol.ActorClassWithSpec(addonsSpec, { + + initialize: function (conn) { + protocol.Actor.prototype.initialize.call(this, conn); + }, + + installTemporaryAddon: Task.async(function* (addonPath) { + let addonFile; + let addon; + try { + addonFile = new FileUtils.File(addonPath); + addon = yield AddonManager.installTemporaryAddon(addonFile); + } catch (error) { + throw new Error(`Could not install add-on at '${addonPath}': ${error}`); + } + + // TODO: once the add-on actor has been refactored to use + // protocol.js, we could return it directly. + // return new BrowserAddonActor(this.conn, addon); + + // Return a pseudo add-on object that a calling client can work + // with. Provide a flag that the client can use to detect when it + // gets upgraded to a real actor object. + return { id: addon.id, actor: false }; + + }), +}); + +exports.AddonsActor = AddonsActor; diff --git a/devtools/server/actors/animation.js b/devtools/server/actors/animation.js new file mode 100644 index 000000000..642c4bcaf --- /dev/null +++ b/devtools/server/actors/animation.js @@ -0,0 +1,751 @@ +/* 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"; + +/** + * Set of actors that expose the Web Animations API to devtools protocol + * clients. + * + * The |Animations| actor is the main entry point. It is used to discover + * animation players on given nodes. + * There should only be one instance per debugger server. + * + * The |AnimationPlayer| actor provides attributes and methods to inspect an + * animation as well as pause/resume/seek it. + * + * The Web Animation spec implementation is ongoing in Gecko, and so this set + * of actors should evolve when the implementation progresses. + * + * References: + * - WebAnimation spec: + * http://w3c.github.io/web-animations/ + * - WebAnimation WebIDL files: + * /dom/webidl/Animation*.webidl + */ + +const {Cu} = require("chrome"); +const promise = require("promise"); +const {Task} = require("devtools/shared/task"); +const protocol = require("devtools/shared/protocol"); +const {Actor, ActorClassWithSpec} = protocol; +const {animationPlayerSpec, animationsSpec} = require("devtools/shared/specs/animation"); +const events = require("sdk/event/core"); + +// Types of animations. +const ANIMATION_TYPES = { + CSS_ANIMATION: "cssanimation", + CSS_TRANSITION: "csstransition", + SCRIPT_ANIMATION: "scriptanimation", + UNKNOWN: "unknown" +}; +exports.ANIMATION_TYPES = ANIMATION_TYPES; + +/** + * The AnimationPlayerActor provides information about a given animation: its + * startTime, currentTime, current state, etc. + * + * Since the state of a player changes as the animation progresses it is often + * useful to call getCurrentState at regular intervals to get the current state. + * + * This actor also allows playing, pausing and seeking the animation. + */ +var AnimationPlayerActor = protocol.ActorClassWithSpec(animationPlayerSpec, { + /** + * @param {AnimationsActor} The main AnimationsActor instance + * @param {AnimationPlayer} The player object returned by getAnimationPlayers + */ + initialize: function (animationsActor, player) { + Actor.prototype.initialize.call(this, animationsActor.conn); + + this.onAnimationMutation = this.onAnimationMutation.bind(this); + + this.walker = animationsActor.walker; + this.player = player; + + // Listen to animation mutations on the node to alert the front when the + // current animation changes. + // If the node is a pseudo-element, then we listen on its parent with + // subtree:true (there's no risk of getting too many notifications in + // onAnimationMutation since we filter out events that aren't for the + // current animation). + this.observer = new this.window.MutationObserver(this.onAnimationMutation); + if (this.isPseudoElement) { + this.observer.observe(this.node.parentElement, + {animations: true, subtree: true}); + } else { + this.observer.observe(this.node, {animations: true}); + } + }, + + destroy: function () { + // Only try to disconnect the observer if it's not already dead (i.e. if the + // container view hasn't navigated since). + if (this.observer && !Cu.isDeadWrapper(this.observer)) { + this.observer.disconnect(); + } + this.player = this.observer = this.walker = null; + + Actor.prototype.destroy.call(this); + }, + + get isPseudoElement() { + return !this.player.effect.target.ownerDocument; + }, + + get node() { + if (this._node) { + return this._node; + } + + let node = this.player.effect.target; + + if (this.isPseudoElement) { + // The target is a CSSPseudoElement object which just has a property that + // points to its parent element and a string type (::before or ::after). + let treeWalker = this.walker.getDocumentWalker(node.parentElement); + while (treeWalker.nextNode()) { + let currentNode = treeWalker.currentNode; + if ((currentNode.nodeName === "_moz_generated_content_before" && + node.type === "::before") || + (currentNode.nodeName === "_moz_generated_content_after" && + node.type === "::after")) { + this._node = currentNode; + } + } + } else { + // The target is a DOM node. + this._node = node; + } + + return this._node; + }, + + get window() { + return this.node.ownerDocument.defaultView; + }, + + /** + * Release the actor, when it isn't needed anymore. + * Protocol.js uses this release method to call the destroy method. + */ + release: function () {}, + + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let data = this.getCurrentState(); + data.actor = this.actorID; + + // If we know the WalkerActor, and if the animated node is known by it, then + // return its corresponding NodeActor ID too. + if (this.walker && this.walker.hasNode(this.node)) { + data.animationTargetNodeActorID = this.walker.getNode(this.node).actorID; + } + + return data; + }, + + isCssAnimation: function (player = this.player) { + return player instanceof this.window.CSSAnimation; + }, + + isCssTransition: function (player = this.player) { + return player instanceof this.window.CSSTransition; + }, + + isScriptAnimation: function (player = this.player) { + return player instanceof this.window.Animation && !( + player instanceof this.window.CSSAnimation || + player instanceof this.window.CSSTransition + ); + }, + + getType: function () { + if (this.isCssAnimation()) { + return ANIMATION_TYPES.CSS_ANIMATION; + } else if (this.isCssTransition()) { + return ANIMATION_TYPES.CSS_TRANSITION; + } else if (this.isScriptAnimation()) { + return ANIMATION_TYPES.SCRIPT_ANIMATION; + } + + return ANIMATION_TYPES.UNKNOWN; + }, + + /** + * Get the name of this animation. This can be either the animation.id + * property if it was set, or the keyframe rule name or the transition + * property. + * @return {String} + */ + getName: function () { + if (this.player.id) { + return this.player.id; + } else if (this.isCssAnimation()) { + return this.player.animationName; + } else if (this.isCssTransition()) { + return this.player.transitionProperty; + } + + return ""; + }, + + /** + * Get the animation duration from this player, in milliseconds. + * @return {Number} + */ + getDuration: function () { + return this.player.effect.getComputedTiming().duration; + }, + + /** + * Get the animation delay from this player, in milliseconds. + * @return {Number} + */ + getDelay: function () { + return this.player.effect.getComputedTiming().delay; + }, + + /** + * Get the animation endDelay from this player, in milliseconds. + * @return {Number} + */ + getEndDelay: function () { + return this.player.effect.getComputedTiming().endDelay; + }, + + /** + * Get the animation iteration count for this player. That is, how many times + * is the animation scheduled to run. + * @return {Number} The number of iterations, or null if the animation repeats + * infinitely. + */ + getIterationCount: function () { + let iterations = this.player.effect.getComputedTiming().iterations; + return iterations === "Infinity" ? null : iterations; + }, + + /** + * Get the animation iterationStart from this player, in ratio. + * That is offset of starting position of the animation. + * @return {Number} + */ + getIterationStart: function () { + return this.player.effect.getComputedTiming().iterationStart; + }, + + /** + * Get the animation easing from this player. + * @return {String} + */ + getEasing: function () { + return this.player.effect.timing.easing; + }, + + /** + * Get the animation fill mode from this player. + * @return {String} + */ + getFill: function () { + return this.player.effect.getComputedTiming().fill; + }, + + /** + * Get the animation direction from this player. + * @return {String} + */ + getDirection: function () { + return this.player.effect.getComputedTiming().direction; + }, + + getPropertiesCompositorStatus: function () { + let properties = this.player.effect.getProperties(); + return properties.map(prop => { + return { + property: prop.property, + runningOnCompositor: prop.runningOnCompositor, + warning: prop.warning + }; + }); + }, + + /** + * Return the current start of the Animation. + * @return {Object} + */ + getState: function () { + // Remember the startTime each time getState is called, it may be useful + // when animations get paused. As in, when an animation gets paused, its + // startTime goes back to null, but the front-end might still be interested + // in knowing what the previous startTime was. So everytime it is set, + // remember it and send it along with the newState. + if (this.player.startTime) { + this.previousStartTime = this.player.startTime; + } + + // Note that if you add a new property to the state object, make sure you + // add the corresponding property in the AnimationPlayerFront' initialState + // getter. + return { + type: this.getType(), + // startTime is null whenever the animation is paused or waiting to start. + startTime: this.player.startTime, + previousStartTime: this.previousStartTime, + currentTime: this.player.currentTime, + playState: this.player.playState, + playbackRate: this.player.playbackRate, + name: this.getName(), + duration: this.getDuration(), + delay: this.getDelay(), + endDelay: this.getEndDelay(), + iterationCount: this.getIterationCount(), + iterationStart: this.getIterationStart(), + fill: this.getFill(), + easing: this.getEasing(), + direction: this.getDirection(), + // animation is hitting the fast path or not. Returns false whenever the + // animation is paused as it is taken off the compositor then. + isRunningOnCompositor: + this.getPropertiesCompositorStatus() + .some(propState => propState.runningOnCompositor), + propertyState: this.getPropertiesCompositorStatus(), + // The document timeline's currentTime is being sent along too. This is + // not strictly related to the node's animationPlayer, but is useful to + // know the current time of the animation with respect to the document's. + documentCurrentTime: this.node.ownerDocument.timeline.currentTime + }; + }, + + /** + * Get the current state of the AnimationPlayer (currentTime, playState, ...). + * Note that the initial state is returned as the form of this actor when it + * is initialized. + * This protocol method only returns a trimed down version of this state in + * case some properties haven't changed since last time (since the front can + * reconstruct those). If you want the full state, use the getState method. + * @return {Object} + */ + getCurrentState: function () { + let newState = this.getState(); + + // If we've saved a state before, compare and only send what has changed. + // It's expected of the front to also save old states to re-construct the + // full state when an incomplete one is received. + // This is to minimize protocol traffic. + let sentState = {}; + if (this.currentState) { + for (let key in newState) { + if (typeof this.currentState[key] === "undefined" || + this.currentState[key] !== newState[key]) { + sentState[key] = newState[key]; + } + } + } else { + sentState = newState; + } + this.currentState = newState; + + return sentState; + }, + + /** + * Executed when the current animation changes, used to emit the new state + * the the front. + */ + onAnimationMutation: function (mutations) { + let isCurrentAnimation = animation => animation === this.player; + let hasCurrentAnimation = animations => animations.some(isCurrentAnimation); + let hasChanged = false; + + for (let {removedAnimations, changedAnimations} of mutations) { + if (hasCurrentAnimation(removedAnimations)) { + // Reset the local copy of the state on removal, since the animation can + // be kept on the client and re-added, its state needs to be sent in + // full. + this.currentState = null; + } + + if (hasCurrentAnimation(changedAnimations)) { + // Only consider the state has having changed if any of delay, duration, + // iterationcount or iterationStart has changed (for now at least). + let newState = this.getState(); + let oldState = this.currentState; + hasChanged = newState.delay !== oldState.delay || + newState.iterationCount !== oldState.iterationCount || + newState.iterationStart !== oldState.iterationStart || + newState.duration !== oldState.duration || + newState.endDelay !== oldState.endDelay; + break; + } + } + + if (hasChanged) { + events.emit(this, "changed", this.getCurrentState()); + } + }, + + /** + * Pause the player. + */ + pause: function () { + this.player.pause(); + return this.player.ready; + }, + + /** + * Play the player. + * This method only returns when the animation has left its pending state. + */ + play: function () { + this.player.play(); + return this.player.ready; + }, + + /** + * Simply exposes the player ready promise. + * + * When an animation is created/paused then played, there's a short time + * during which its playState is pending, before being set to running. + * + * If you either created a new animation using the Web Animations API or + * paused/played an existing one, and then want to access the playState, you + * might be interested to call this method. + * This is especially important for tests. + */ + ready: function () { + return this.player.ready; + }, + + /** + * Set the current time of the animation player. + */ + setCurrentTime: function (currentTime) { + // The spec is that the progress of animation is changed + // if the time of setCurrentTime is during the endDelay. + // We should prevent the time + // to make the same animation behavior as the original. + // Likewise, in case the time is less than 0. + const timing = this.player.effect.getComputedTiming(); + if (timing.delay < 0) { + currentTime += timing.delay; + } + if (currentTime < 0) { + currentTime = 0; + } else if (currentTime * this.player.playbackRate > timing.endTime) { + currentTime = timing.endTime; + } + this.player.currentTime = currentTime * this.player.playbackRate; + }, + + /** + * Set the playback rate of the animation player. + */ + setPlaybackRate: function (playbackRate) { + this.player.playbackRate = playbackRate; + }, + + /** + * Get data about the keyframes of this animation player. + * @return {Object} Returns a list of frames, each frame containing the list + * animated properties as well as the frame's offset. + */ + getFrames: function () { + return this.player.effect.getKeyframes(); + }, + + /** + * Get data about the animated properties of this animation player. + * @return {Array} Returns a list of animated properties. + * Each property contains a list of values and their offsets + */ + getProperties: function () { + return this.player.effect.getProperties().map(property => { + return {name: property.property, values: property.values}; + }); + } +}); + +exports.AnimationPlayerActor = AnimationPlayerActor; + +/** + * The Animations actor lists animation players for a given node. + */ +var AnimationsActor = exports.AnimationsActor = protocol.ActorClassWithSpec(animationsSpec, { + initialize: function(conn, tabActor) { + Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + + this.onWillNavigate = this.onWillNavigate.bind(this); + this.onNavigate = this.onNavigate.bind(this); + this.onAnimationMutation = this.onAnimationMutation.bind(this); + + this.allAnimationsPaused = false; + events.on(this.tabActor, "will-navigate", this.onWillNavigate); + events.on(this.tabActor, "navigate", this.onNavigate); + }, + + destroy: function () { + Actor.prototype.destroy.call(this); + events.off(this.tabActor, "will-navigate", this.onWillNavigate); + events.off(this.tabActor, "navigate", this.onNavigate); + + this.stopAnimationPlayerUpdates(); + this.tabActor = this.observer = this.actors = this.walker = null; + }, + + /** + * Since AnimationsActor doesn't have a protocol.js parent actor that takes + * care of its lifetime, implementing disconnect is required to cleanup. + */ + disconnect: function () { + this.destroy(); + }, + + /** + * Clients can optionally call this with a reference to their WalkerActor. + * If they do, then AnimationPlayerActor's forms are going to also include + * NodeActor IDs when the corresponding NodeActors do exist. + * This, in turns, is helpful for clients to avoid having to go back once more + * to the server to get a NodeActor for a particular animation. + * @param {WalkerActor} walker + */ + setWalkerActor: function (walker) { + this.walker = walker; + }, + + /** + * Retrieve the list of AnimationPlayerActor actors for currently running + * animations on a node and its descendants. + * Note that calling this method a second time will destroy all previously + * retrieved AnimationPlayerActors. Indeed, the lifecycle of these actors + * is managed here on the server and tied to getAnimationPlayersForNode + * being called. + * @param {NodeActor} nodeActor The NodeActor as defined in + * /devtools/server/actors/inspector + */ + getAnimationPlayersForNode: function (nodeActor) { + let animations = nodeActor.rawNode.getAnimations({subtree: true}); + + // Destroy previously stored actors + if (this.actors) { + this.actors.forEach(actor => actor.destroy()); + } + this.actors = []; + + for (let i = 0; i < animations.length; i++) { + let actor = AnimationPlayerActor(this, animations[i]); + this.actors.push(actor); + } + + // When a front requests the list of players for a node, start listening + // for animation mutations on this node to send updates to the front, until + // either getAnimationPlayersForNode is called again or + // stopAnimationPlayerUpdates is called. + this.stopAnimationPlayerUpdates(); + let win = nodeActor.rawNode.ownerDocument.defaultView; + this.observer = new win.MutationObserver(this.onAnimationMutation); + this.observer.observe(nodeActor.rawNode, { + animations: true, + subtree: true + }); + + return this.actors; + }, + + onAnimationMutation: function (mutations) { + let eventData = []; + let readyPromises = []; + + for (let {addedAnimations, removedAnimations} of mutations) { + for (let player of removedAnimations) { + // Note that animations are reported as removed either when they are + // actually removed from the node (e.g. css class removed) or when they + // are finished and don't have forwards animation-fill-mode. + // In the latter case, we don't send an event, because the corresponding + // animation can still be seeked/resumed, so we want the client to keep + // its reference to the AnimationPlayerActor. + if (player.playState !== "idle") { + continue; + } + + let index = this.actors.findIndex(a => a.player === player); + if (index !== -1) { + eventData.push({ + type: "removed", + player: this.actors[index] + }); + this.actors.splice(index, 1); + } + } + + for (let player of addedAnimations) { + // If the added player already exists, it means we previously filtered + // it out when it was reported as removed. So filter it out here too. + if (this.actors.find(a => a.player === player)) { + continue; + } + + // If the added player has the same name and target node as a player we + // already have, it means it's a transition that's re-starting. So send + // a "removed" event for the one we already have. + let index = this.actors.findIndex(a => { + let isSameType = a.player.constructor === player.constructor; + let isSameName = (a.isCssAnimation() && + a.player.animationName === player.animationName) || + (a.isCssTransition() && + a.player.transitionProperty === player.transitionProperty); + let isSameNode = a.player.effect.target === player.effect.target; + + return isSameType && isSameNode && isSameName; + }); + if (index !== -1) { + eventData.push({ + type: "removed", + player: this.actors[index] + }); + this.actors.splice(index, 1); + } + + let actor = AnimationPlayerActor(this, player); + this.actors.push(actor); + eventData.push({ + type: "added", + player: actor + }); + readyPromises.push(player.ready); + } + } + + if (eventData.length) { + // Let's wait for all added animations to be ready before telling the + // front-end. + Promise.all(readyPromises).then(() => { + events.emit(this, "mutations", eventData); + }); + } + }, + + /** + * After the client has called getAnimationPlayersForNode for a given DOM + * node, the actor starts sending animation mutations for this node. If the + * client doesn't want this to happen anymore, it should call this method. + */ + stopAnimationPlayerUpdates: function () { + if (this.observer && !Cu.isDeadWrapper(this.observer)) { + this.observer.disconnect(); + } + }, + + /** + * Iterates through all nodes below a given rootNode (optionally also in + * nested frames) and finds all existing animation players. + * @param {DOMNode} rootNode The root node to start iterating at. Animation + * players will *not* be reported for this node. + * @param {Boolean} traverseFrames Whether we should iterate through nested + * frames too. + * @return {Array} An array of AnimationPlayer objects. + */ + getAllAnimations: function (rootNode, traverseFrames) { + if (!traverseFrames) { + return rootNode.getAnimations({subtree: true}); + } + + let animations = []; + for (let {document} of this.tabActor.windows) { + animations = [...animations, ...document.getAnimations({subtree: true})]; + } + return animations; + }, + + onWillNavigate: function ({isTopLevel}) { + if (isTopLevel) { + this.stopAnimationPlayerUpdates(); + } + }, + + onNavigate: function ({isTopLevel}) { + if (isTopLevel) { + this.allAnimationsPaused = false; + } + }, + + /** + * Pause all animations in the current tabActor's frames. + */ + pauseAll: function () { + let readyPromises = []; + // Until the WebAnimations API provides a way to play/pause via the document + // timeline, we have to iterate through the whole DOM to find all players. + for (let player of + this.getAllAnimations(this.tabActor.window.document, true)) { + player.pause(); + readyPromises.push(player.ready); + } + this.allAnimationsPaused = true; + return promise.all(readyPromises); + }, + + /** + * Play all animations in the current tabActor's frames. + * This method only returns when animations have left their pending states. + */ + playAll: function () { + let readyPromises = []; + // Until the WebAnimations API provides a way to play/pause via the document + // timeline, we have to iterate through the whole DOM to find all players. + for (let player of + this.getAllAnimations(this.tabActor.window.document, true)) { + player.play(); + readyPromises.push(player.ready); + } + this.allAnimationsPaused = false; + return promise.all(readyPromises); + }, + + toggleAll: function () { + if (this.allAnimationsPaused) { + return this.playAll(); + } + return this.pauseAll(); + }, + + /** + * Toggle (play/pause) several animations at the same time. + * @param {Array} players A list of AnimationPlayerActor objects. + * @param {Boolean} shouldPause If set to true, the players will be paused, + * otherwise they will be played. + */ + toggleSeveral: function (players, shouldPause) { + return promise.all(players.map(player => { + return shouldPause ? player.pause() : player.play(); + })); + }, + + /** + * Set the current time of several animations at the same time. + * @param {Array} players A list of AnimationPlayerActor. + * @param {Number} time The new currentTime. + * @param {Boolean} shouldPause Should the players be paused too. + */ + setCurrentTimes: function (players, time, shouldPause) { + return promise.all(players.map(player => { + let pause = shouldPause ? player.pause() : promise.resolve(); + return pause.then(() => player.setCurrentTime(time)); + })); + }, + + /** + * Set the playback rate of several animations at the same time. + * @param {Array} players A list of AnimationPlayerActor. + * @param {Number} rate The new rate. + */ + setPlaybackRates: function (players, rate) { + for (let player of players) { + player.setPlaybackRate(rate); + } + } +}); diff --git a/devtools/server/actors/breakpoint.js b/devtools/server/actors/breakpoint.js new file mode 100644 index 000000000..547dcd0f1 --- /dev/null +++ b/devtools/server/actors/breakpoint.js @@ -0,0 +1,189 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; 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 { ActorClassWithSpec } = require("devtools/shared/protocol"); +const { breakpointSpec } = require("devtools/shared/specs/breakpoint"); + +/** + * Set breakpoints on all the given entry points with the given + * BreakpointActor as the handler. + * + * @param BreakpointActor actor + * The actor handling the breakpoint hits. + * @param Array entryPoints + * An array of objects of the form `{ script, offsets }`. + */ +function setBreakpointAtEntryPoints(actor, entryPoints) { + for (let { script, offsets } of entryPoints) { + actor.addScript(script); + for (let offset of offsets) { + script.setBreakpoint(offset, actor); + } + } +} + +exports.setBreakpointAtEntryPoints = setBreakpointAtEntryPoints; + +/** + * BreakpointActors exist for the lifetime of their containing thread and are + * responsible for deleting breakpoints, handling breakpoint hits and + * associating breakpoints with scripts. + */ +let BreakpointActor = ActorClassWithSpec(breakpointSpec, { + /** + * Create a Breakpoint actor. + * + * @param ThreadActor threadActor + * The parent thread actor that contains this breakpoint. + * @param OriginalLocation originalLocation + * The original location of the breakpoint. + */ + initialize: function (threadActor, originalLocation) { + // The set of Debugger.Script instances that this breakpoint has been set + // upon. + this.scripts = new Set(); + + this.threadActor = threadActor; + this.originalLocation = originalLocation; + this.condition = null; + this.isPending = true; + }, + + disconnect: function () { + this.removeScripts(); + }, + + hasScript: function (script) { + return this.scripts.has(script); + }, + + /** + * Called when this same breakpoint is added to another Debugger.Script + * instance. + * + * @param script Debugger.Script + * The new source script on which the breakpoint has been set. + */ + addScript: function (script) { + this.scripts.add(script); + this.isPending = false; + }, + + /** + * Remove the breakpoints from associated scripts and clear the script cache. + */ + removeScripts: function () { + for (let script of this.scripts) { + script.clearBreakpoint(this); + } + this.scripts.clear(); + }, + + /** + * Check if this breakpoint has a condition that doesn't error and + * evaluates to true in frame. + * + * @param frame Debugger.Frame + * The frame to evaluate the condition in + * @returns Object + * - result: boolean|undefined + * True when the conditional breakpoint should trigger a pause, + * false otherwise. If the condition evaluation failed/killed, + * `result` will be `undefined`. + * - message: string + * If the condition throws, this is the thrown message. + */ + checkCondition: function (frame) { + let completion = frame.eval(this.condition); + if (completion) { + if (completion.throw) { + // The evaluation failed and threw + let message = "Unknown exception"; + try { + if (completion.throw.getOwnPropertyDescriptor) { + message = completion.throw.getOwnPropertyDescriptor("message") + .value; + } else if (completion.toString) { + message = completion.toString(); + } + } catch (ex) {} + return { + result: true, + message: message + }; + } else if (completion.yield) { + assert(false, "Shouldn't ever get yield completions from an eval"); + } else { + return { result: completion.return ? true : false }; + } + } else { + // The evaluation was killed (possibly by the slow script dialog) + return { result: undefined }; + } + }, + + /** + * A function that the engine calls when a breakpoint has been hit. + * + * @param frame Debugger.Frame + * The stack frame that contained the breakpoint. + */ + hit: function (frame) { + // Don't pause if we are currently stepping (in or over) or the frame is + // black-boxed. + let generatedLocation = this.threadActor.sources.getFrameLocation(frame); + let { originalSourceActor } = this.threadActor.unsafeSynchronize( + this.threadActor.sources.getOriginalLocation(generatedLocation)); + let url = originalSourceActor.url; + + if (this.threadActor.sources.isBlackBoxed(url) + || frame.onStep) { + return undefined; + } + + let reason = {}; + + if (this.threadActor._hiddenBreakpoints.has(this.actorID)) { + reason.type = "pauseOnDOMEvents"; + } else if (!this.condition) { + reason.type = "breakpoint"; + // TODO: add the rest of the breakpoints on that line (bug 676602). + reason.actors = [ this.actorID ]; + } else { + let { result, message } = this.checkCondition(frame); + + if (result) { + if (!message) { + reason.type = "breakpoint"; + } else { + reason.type = "breakpointConditionThrown"; + reason.message = message; + } + reason.actors = [ this.actorID ]; + } else { + return undefined; + } + } + return this.threadActor._pauseAndRespond(frame, reason); + }, + + /** + * Handle a protocol request to remove this breakpoint. + */ + delete: function () { + // Remove from the breakpoint store. + if (this.originalLocation) { + this.threadActor.breakpointActorMap.deleteActor(this.originalLocation); + } + this.threadActor.threadLifetimePool.removeActor(this); + // Remove the actual breakpoint from the associated scripts. + this.removeScripts(); + } +}); + +exports.BreakpointActor = BreakpointActor; diff --git a/devtools/server/actors/call-watcher.js b/devtools/server/actors/call-watcher.js new file mode 100644 index 000000000..5729f9508 --- /dev/null +++ b/devtools/server/actors/call-watcher.js @@ -0,0 +1,634 @@ +/* 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 {Cc, Ci, Cu, Cr} = require("chrome"); +const events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const {serializeStack, parseStack} = require("toolkit/loader"); + +const {on, once, off, emit} = events; +const {method, Arg, Option, RetVal} = protocol; + +const { functionCallSpec, callWatcherSpec } = require("devtools/shared/specs/call-watcher"); +const { CallWatcherFront } = require("devtools/shared/fronts/call-watcher"); + +/** + * This actor contains information about a function call, like the function + * type, name, stack, arguments, returned value etc. + */ +var FunctionCallActor = protocol.ActorClassWithSpec(functionCallSpec, { + /** + * Creates the function call actor. + * + * @param DebuggerServerConnection conn + * The server connection. + * @param DOMWindow window + * The content window. + * @param string global + * The name of the global object owning this function, like + * "CanvasRenderingContext2D" or "WebGLRenderingContext". + * @param object caller + * The object owning the function when it was called. + * For example, in `foo.bar()`, the caller is `foo`. + * @param number type + * Either METHOD_FUNCTION, METHOD_GETTER or METHOD_SETTER. + * @param string name + * The called function's name. + * @param array stack + * The called function's stack, as a list of { name, file, line } objects. + * @param number timestamp + * The performance.now() timestamp when the function was called. + * @param array args + * The called function's arguments. + * @param any result + * The value returned by the function call. + * @param boolean holdWeak + * Determines whether or not FunctionCallActor stores a weak reference + * to the underlying objects. + */ + initialize: function (conn, [window, global, caller, type, name, stack, timestamp, args, result], holdWeak) { + protocol.Actor.prototype.initialize.call(this, conn); + + this.details = { + global: global, + type: type, + name: name, + stack: stack, + timestamp: timestamp + }; + + // Store a weak reference to all objects so we don't + // prevent natural GC if `holdWeak` was passed into + // setup as truthy. + if (holdWeak) { + let weakRefs = { + window: Cu.getWeakReference(window), + caller: Cu.getWeakReference(caller), + args: Cu.getWeakReference(args), + result: Cu.getWeakReference(result), + }; + + Object.defineProperties(this.details, { + window: { get: () => weakRefs.window.get() }, + caller: { get: () => weakRefs.caller.get() }, + args: { get: () => weakRefs.args.get() }, + result: { get: () => weakRefs.result.get() }, + }); + } + // Otherwise, hold strong references to the objects. + else { + this.details.window = window; + this.details.caller = caller; + this.details.args = args; + this.details.result = result; + } + + // The caller, args and results are string names for now. It would + // certainly be nicer if they were Object actors. Make this smarter, so + // that the frontend can inspect each argument, be it object or primitive. + // Bug 978960. + this.details.previews = { + caller: this._generateStringPreview(caller), + args: this._generateArgsPreview(args), + result: this._generateStringPreview(result) + }; + }, + + /** + * Customize the marshalling of this actor to provide some generic information + * directly on the Front instance. + */ + form: function () { + return { + actor: this.actorID, + type: this.details.type, + name: this.details.name, + file: this.details.stack[0].file, + line: this.details.stack[0].line, + timestamp: this.details.timestamp, + callerPreview: this.details.previews.caller, + argsPreview: this.details.previews.args, + resultPreview: this.details.previews.result + }; + }, + + /** + * Gets more information about this function call, which is not necessarily + * available on the Front instance. + */ + getDetails: function () { + let { type, name, stack, timestamp } = this.details; + + // Since not all calls on the stack have corresponding owner files (e.g. + // callbacks of a requestAnimationFrame etc.), there's no benefit in + // returning them, as the user can't jump to the Debugger from them. + for (let i = stack.length - 1; ;) { + if (stack[i].file) { + break; + } + stack.pop(); + i--; + } + + // XXX: Use grips for objects and serialize them properly, in order + // to add the function's caller, arguments and return value. Bug 978957. + return { + type: type, + name: name, + stack: stack, + timestamp: timestamp + }; + }, + + /** + * Serializes the arguments so that they can be easily be transferred + * as a string, but still be useful when displayed in a potential UI. + * + * @param array args + * The source arguments. + * @return string + * The arguments as a string. + */ + _generateArgsPreview: function (args) { + let { global, name, caller } = this.details; + + // Get method signature to determine if there are any enums + // used in this method. + let methodSignatureEnums; + + let knownGlobal = CallWatcherFront.KNOWN_METHODS[global]; + if (knownGlobal) { + let knownMethod = knownGlobal[name]; + if (knownMethod) { + let isOverloaded = typeof knownMethod.enums === "function"; + if (isOverloaded) { + methodSignatureEnums = methodSignatureEnums(args); + } else { + methodSignatureEnums = knownMethod.enums; + } + } + } + + let serializeArgs = () => args.map((arg, i) => { + // XXX: Bug 978960. + if (arg === undefined) { + return "undefined"; + } + if (arg === null) { + return "null"; + } + if (typeof arg == "function") { + return "Function"; + } + if (typeof arg == "object") { + return "Object"; + } + // If this argument matches the method's signature + // and is an enum, change it to its constant name. + if (methodSignatureEnums && methodSignatureEnums.has(i)) { + return getBitToEnumValue(global, caller, arg); + } + return arg + ""; + }); + + return serializeArgs().join(", "); + }, + + /** + * Serializes the data so that it can be easily be transferred + * as a string, but still be useful when displayed in a potential UI. + * + * @param object data + * The source data. + * @return string + * The arguments as a string. + */ + _generateStringPreview: function (data) { + // XXX: Bug 978960. + if (data === undefined) { + return "undefined"; + } + if (data === null) { + return "null"; + } + if (typeof data == "function") { + return "Function"; + } + if (typeof data == "object") { + return "Object"; + } + return data + ""; + } +}); + +/** + * This actor observes function calls on certain objects or globals. + */ +var CallWatcherActor = exports.CallWatcherActor = protocol.ActorClassWithSpec(callWatcherSpec, { + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this._onGlobalCreated = this._onGlobalCreated.bind(this); + this._onGlobalDestroyed = this._onGlobalDestroyed.bind(this); + this._onContentFunctionCall = this._onContentFunctionCall.bind(this); + on(this.tabActor, "window-ready", this._onGlobalCreated); + on(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + }, + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + off(this.tabActor, "window-ready", this._onGlobalCreated); + off(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + this.finalize(); + }, + + /** + * Lightweight listener invoked whenever an instrumented function is called + * while recording. We're doing this to avoid the event emitter overhead, + * since this is expected to be a very hot function. + */ + onCall: null, + + /** + * Starts waiting for the current tab actor's document global to be + * created, in order to instrument the specified objects and become + * aware of everything the content does with them. + */ + setup: function ({ tracedGlobals, tracedFunctions, startRecording, performReload, holdWeak, storeCalls }) { + if (this._initialized) { + return; + } + this._initialized = true; + this._timestampEpoch = 0; + + this._functionCalls = []; + this._tracedGlobals = tracedGlobals || []; + this._tracedFunctions = tracedFunctions || []; + this._holdWeak = !!holdWeak; + this._storeCalls = !!storeCalls; + + if (startRecording) { + this.resumeRecording(); + } + if (performReload) { + this.tabActor.window.location.reload(); + } + }, + + /** + * Stops listening for document global changes and puts this actor + * to hibernation. This method is called automatically just before the + * actor is destroyed. + */ + finalize: function () { + if (!this._initialized) { + return; + } + this._initialized = false; + this._finalized = true; + + this._tracedGlobals = null; + this._tracedFunctions = null; + }, + + /** + * Returns whether the instrumented function calls are currently recorded. + */ + isRecording: function () { + return this._recording; + }, + + /** + * Initialize the timestamp epoch used to offset function call timestamps. + */ + initTimestampEpoch: function () { + this._timestampEpoch = this.tabActor.window.performance.now(); + }, + + /** + * Starts recording function calls. + */ + resumeRecording: function () { + this._recording = true; + }, + + /** + * Stops recording function calls. + */ + pauseRecording: function () { + this._recording = false; + return this._functionCalls; + }, + + /** + * Erases all the recorded function calls. + * Calling `resumeRecording` or `pauseRecording` does not erase history. + */ + eraseRecording: function () { + this._functionCalls = []; + }, + + /** + * Invoked whenever the current tab actor's document global is created. + */ + _onGlobalCreated: function ({window, id, isTopLevel}) { + if (!this._initialized) { + return; + } + + // TODO: bug 981748, support more than just the top-level documents. + if (!isTopLevel) { + return; + } + + let self = this; + this._tracedWindowId = id; + + let unwrappedWindow = XPCNativeWrapper.unwrap(window); + let callback = this._onContentFunctionCall; + + for (let global of this._tracedGlobals) { + let prototype = unwrappedWindow[global].prototype; + let properties = Object.keys(prototype); + properties.forEach(name => overrideSymbol(global, prototype, name, callback)); + } + + for (let name of this._tracedFunctions) { + overrideSymbol("window", unwrappedWindow, name, callback); + } + + /** + * Instruments a method, getter or setter on the specified target object to + * invoke a callback whenever it is called. + */ + function overrideSymbol(global, target, name, callback) { + let propertyDescriptor = Object.getOwnPropertyDescriptor(target, name); + + if (propertyDescriptor.get || propertyDescriptor.set) { + overrideAccessor(global, target, name, propertyDescriptor, callback); + return; + } + if (propertyDescriptor.writable && typeof propertyDescriptor.value == "function") { + overrideFunction(global, target, name, propertyDescriptor, callback); + return; + } + } + + /** + * Instruments a function on the specified target object. + */ + function overrideFunction(global, target, name, descriptor, callback) { + // Invoking .apply on an unxrayed content function doesn't work, because + // the arguments array is inaccessible to it. Get Xrays back. + let originalFunc = Cu.unwaiveXrays(target[name]); + + Cu.exportFunction(function (...args) { + let result; + try { + result = Cu.waiveXrays(originalFunc.apply(this, args)); + } catch (e) { + throw createContentError(e, unwrappedWindow); + } + + if (self._recording) { + let type = CallWatcherFront.METHOD_FUNCTION; + let stack = getStack(name); + let timestamp = self.tabActor.window.performance.now() - self._timestampEpoch; + callback(unwrappedWindow, global, this, type, name, stack, timestamp, args, result); + } + return result; + }, target, { defineAs: name }); + + Object.defineProperty(target, name, { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + writable: true + }); + } + + /** + * Instruments a getter or setter on the specified target object. + */ + function overrideAccessor(global, target, name, descriptor, callback) { + // Invoking .apply on an unxrayed content function doesn't work, because + // the arguments array is inaccessible to it. Get Xrays back. + let originalGetter = Cu.unwaiveXrays(target.__lookupGetter__(name)); + let originalSetter = Cu.unwaiveXrays(target.__lookupSetter__(name)); + + Object.defineProperty(target, name, { + get: function (...args) { + if (!originalGetter) return undefined; + let result = Cu.waiveXrays(originalGetter.apply(this, args)); + + if (self._recording) { + let type = CallWatcherFront.GETTER_FUNCTION; + let stack = getStack(name); + let timestamp = self.tabActor.window.performance.now() - self._timestampEpoch; + callback(unwrappedWindow, global, this, type, name, stack, timestamp, args, result); + } + return result; + }, + set: function (...args) { + if (!originalSetter) return; + originalSetter.apply(this, args); + + if (self._recording) { + let type = CallWatcherFront.SETTER_FUNCTION; + let stack = getStack(name); + let timestamp = self.tabActor.window.performance.now() - self._timestampEpoch; + callback(unwrappedWindow, global, this, type, name, stack, timestamp, args, undefined); + } + }, + configurable: descriptor.configurable, + enumerable: descriptor.enumerable + }); + } + + /** + * Stores the relevant information about calls on the stack when + * a function is called. + */ + function getStack(caller) { + try { + // Using Components.stack wouldn't be a better idea, since it's + // much slower because it attempts to retrieve the C++ stack as well. + throw new Error(); + } catch (e) { + var stack = e.stack; + } + + // Of course, using a simple regex like /(.*?)@(.*):(\d*):\d*/ would be + // much prettier, but this is a very hot function, so let's sqeeze + // every drop of performance out of it. + let calls = []; + let callIndex = 0; + let currNewLinePivot = stack.indexOf("\n") + 1; + let nextNewLinePivot = stack.indexOf("\n", currNewLinePivot); + + while (nextNewLinePivot > 0) { + let nameDelimiterIndex = stack.indexOf("@", currNewLinePivot); + let columnDelimiterIndex = stack.lastIndexOf(":", nextNewLinePivot - 1); + let lineDelimiterIndex = stack.lastIndexOf(":", columnDelimiterIndex - 1); + + if (!calls[callIndex]) { + calls[callIndex] = { name: "", file: "", line: 0 }; + } + if (!calls[callIndex + 1]) { + calls[callIndex + 1] = { name: "", file: "", line: 0 }; + } + + if (callIndex > 0) { + let file = stack.substring(nameDelimiterIndex + 1, lineDelimiterIndex); + let line = stack.substring(lineDelimiterIndex + 1, columnDelimiterIndex); + let name = stack.substring(currNewLinePivot, nameDelimiterIndex); + calls[callIndex].name = name; + calls[callIndex - 1].file = file; + calls[callIndex - 1].line = line; + } else { + // Since the topmost stack frame is actually our overwritten function, + // it will not have the expected name. + calls[0].name = caller; + } + + currNewLinePivot = nextNewLinePivot + 1; + nextNewLinePivot = stack.indexOf("\n", currNewLinePivot); + callIndex++; + } + + return calls; + } + }, + + /** + * Invoked whenever the current tab actor's inner window is destroyed. + */ + _onGlobalDestroyed: function ({window, id, isTopLevel}) { + if (this._tracedWindowId == id) { + this.pauseRecording(); + this.eraseRecording(); + this._timestampEpoch = 0; + } + }, + + /** + * Invoked whenever an instrumented function is called. + */ + _onContentFunctionCall: function (...details) { + // If the consuming tool has finalized call-watcher, ignore the + // still-instrumented calls. + if (this._finalized) { + return; + } + + let functionCall = new FunctionCallActor(this.conn, details, this._holdWeak); + + if (this._storeCalls) { + this._functionCalls.push(functionCall); + } + + if (this.onCall) { + this.onCall(functionCall); + } else { + emit(this, "call", functionCall); + } + } +}); + +/** + * A lookup table for cross-referencing flags or properties with their name + * assuming they look LIKE_THIS most of the time. + * + * For example, when gl.clear(gl.COLOR_BUFFER_BIT) is called, the actual passed + * argument's value is 16384, which we want identified as "COLOR_BUFFER_BIT". + */ +var gEnumRegex = /^[A-Z][A-Z0-9_]+$/; +var gEnumsLookupTable = {}; + +// These values are returned from errors, or empty values, +// and need to be ignored when checking arguments due to the bitwise math. +var INVALID_ENUMS = [ + "INVALID_ENUM", "NO_ERROR", "INVALID_VALUE", "OUT_OF_MEMORY", "NONE" +]; + +function getBitToEnumValue(type, object, arg) { + let table = gEnumsLookupTable[type]; + + // If mapping not yet created, do it on the first run. + if (!table) { + table = gEnumsLookupTable[type] = {}; + + for (let key in object) { + if (key.match(gEnumRegex)) { + // Maps `16384` to `"COLOR_BUFFER_BIT"`, etc. + table[object[key]] = key; + } + } + } + + // If a single bit value, just return it. + if (table[arg]) { + return table[arg]; + } + + // Otherwise, attempt to reduce it to the original bit flags: + // `16640` -> "COLOR_BUFFER_BIT | DEPTH_BUFFER_BIT" + let flags = []; + for (let flag in table) { + if (INVALID_ENUMS.indexOf(table[flag]) !== -1) { + continue; + } + + // Cast to integer as all values are stored as strings + // in `table` + flag = flag | 0; + if (flag && (arg & flag) === flag) { + flags.push(table[flag]); + } + } + + // Cache the combined bitmask value + return table[arg] = flags.join(" | ") || arg; +} + +/** + * Creates a new error from an error that originated from content but was called + * from a wrapped overridden method. This is so we can make our own error + * that does not look like it originated from the call watcher. + * + * We use toolkit/loader's parseStack and serializeStack rather than the + * parsing done in the local `getStack` function, because it does not expose + * column number, would have to change the protocol models `call-stack-items` and `call-details` + * which hurts backwards compatibility, and the local `getStack` is an optimized, hot function. + */ +function createContentError(e, win) { + let { message, name, stack } = e; + let parsedStack = parseStack(stack); + let { fileName, lineNumber, columnNumber } = parsedStack[parsedStack.length - 1]; + let error; + + let isDOMException = e instanceof Ci.nsIDOMDOMException; + let constructor = isDOMException ? win.DOMException : (win[e.name] || win.Error); + + if (isDOMException) { + error = new constructor(message, name); + Object.defineProperties(error, { + code: { value: e.code }, + columnNumber: { value: 0 }, // columnNumber is always 0 for DOMExceptions? + filename: { value: fileName }, // note the lowercase `filename` + lineNumber: { value: lineNumber }, + result: { value: e.result }, + stack: { value: serializeStack(parsedStack) } + }); + } + else { + // Constructing an error here retains all the stack information, + // and we can add message, fileName and lineNumber via constructor, though + // need to manually add columnNumber. + error = new constructor(message, fileName, lineNumber); + Object.defineProperty(error, "columnNumber", { + value: columnNumber + }); + } + return error; +} diff --git a/devtools/server/actors/canvas.js b/devtools/server/actors/canvas.js new file mode 100644 index 000000000..f6e1f57ec --- /dev/null +++ b/devtools/server/actors/canvas.js @@ -0,0 +1,728 @@ +/* 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 {Cc, Ci, Cu, Cr} = require("chrome"); +const events = require("sdk/event/core"); +const promise = require("promise"); +const protocol = require("devtools/shared/protocol"); +const {CallWatcherActor} = require("devtools/server/actors/call-watcher"); +const {CallWatcherFront} = require("devtools/shared/fronts/call-watcher"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const {WebGLPrimitiveCounter} = require("devtools/server/primitive"); +const { + frameSnapshotSpec, + canvasSpec, + CANVAS_CONTEXTS, + ANIMATION_GENERATORS, + LOOP_GENERATORS, + DRAW_CALLS, + INTERESTING_CALLS, +} = require("devtools/shared/specs/canvas"); +const {CanvasFront} = require("devtools/shared/fronts/canvas"); + +const {on, once, off, emit} = events; +const {method, custom, Arg, Option, RetVal} = protocol; + +/** + * This actor represents a recorded animation frame snapshot, along with + * all the corresponding canvas' context methods invoked in that frame, + * thumbnails for each draw call and a screenshot of the end result. + */ +var FrameSnapshotActor = protocol.ActorClassWithSpec(frameSnapshotSpec, { + /** + * Creates the frame snapshot call actor. + * + * @param DebuggerServerConnection conn + * The server connection. + * @param HTMLCanvasElement canvas + * A reference to the content canvas. + * @param array calls + * An array of "function-call" actor instances. + * @param object screenshot + * A single "snapshot-image" type instance. + */ + initialize: function (conn, { canvas, calls, screenshot, primitive }) { + protocol.Actor.prototype.initialize.call(this, conn); + this._contentCanvas = canvas; + this._functionCalls = calls; + this._animationFrameEndScreenshot = screenshot; + this._primitive = primitive; + }, + + /** + * Gets as much data about this snapshot without computing anything costly. + */ + getOverview: function () { + return { + calls: this._functionCalls, + thumbnails: this._functionCalls.map(e => e._thumbnail).filter(e => !!e), + screenshot: this._animationFrameEndScreenshot, + primitive: { + tris: this._primitive.tris, + vertices: this._primitive.vertices, + points: this._primitive.points, + lines: this._primitive.lines + } + }; + }, + + /** + * Gets a screenshot of the canvas's contents after the specified + * function was called. + */ + generateScreenshotFor: function (functionCall) { + let caller = functionCall.details.caller; + let global = functionCall.details.global; + + let canvas = this._contentCanvas; + let calls = this._functionCalls; + let index = calls.indexOf(functionCall); + + // To get a screenshot, replay all the steps necessary to render the frame, + // by invoking the context calls up to and including the specified one. + // This will be done in a custom framebuffer in case of a WebGL context. + let replayData = ContextUtils.replayAnimationFrame({ + contextType: global, + canvas: canvas, + calls: calls, + first: 0, + last: index + }); + + let { replayContext, replayContextScaling, lastDrawCallIndex, doCleanup } = replayData; + let [left, top, width, height] = replayData.replayViewport; + let screenshot; + + // Depending on the canvas' context, generating a screenshot is done + // in different ways. + if (global == "WebGLRenderingContext") { + screenshot = ContextUtils.getPixelsForWebGL(replayContext, left, top, width, height); + screenshot.flipped = true; + } else if (global == "CanvasRenderingContext2D") { + screenshot = ContextUtils.getPixelsFor2D(replayContext, left, top, width, height); + screenshot.flipped = false; + } + + // In case of the WebGL context, we also need to reset the framebuffer + // binding to the original value, after generating the screenshot. + doCleanup(); + + screenshot.scaling = replayContextScaling; + screenshot.index = lastDrawCallIndex; + return screenshot; + } +}); + +/** + * This Canvas Actor handles simple instrumentation of all the methods + * of a 2D or WebGL context, to provide information regarding all the calls + * made when drawing frame inside an animation loop. + */ +var CanvasActor = exports.CanvasActor = protocol.ActorClassWithSpec(canvasSpec, { + // Reset for each recording, boolean indicating whether or not + // any draw calls were called for a recording. + _animationContainsDrawCall: false, + + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this._webGLPrimitiveCounter = new WebGLPrimitiveCounter(tabActor); + this._onContentFunctionCall = this._onContentFunctionCall.bind(this); + }, + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + this._webGLPrimitiveCounter.destroy(); + this.finalize(); + }, + + /** + * Starts listening for function calls. + */ + setup: function ({ reload }) { + if (this._initialized) { + if (reload) { + this.tabActor.window.location.reload(); + } + return; + } + this._initialized = true; + + this._callWatcher = new CallWatcherActor(this.conn, this.tabActor); + this._callWatcher.onCall = this._onContentFunctionCall; + this._callWatcher.setup({ + tracedGlobals: CANVAS_CONTEXTS, + tracedFunctions: [...ANIMATION_GENERATORS, ...LOOP_GENERATORS], + performReload: reload, + storeCalls: true + }); + }, + + /** + * Stops listening for function calls. + */ + finalize: function () { + if (!this._initialized) { + return; + } + this._initialized = false; + + this._callWatcher.finalize(); + this._callWatcher = null; + }, + + /** + * Returns whether this actor has been set up. + */ + isInitialized: function () { + return !!this._initialized; + }, + + /** + * Returns whether or not the CanvasActor is recording an animation. + * Used in tests. + */ + isRecording: function () { + return !!this._callWatcher.isRecording(); + }, + + /** + * Records a snapshot of all the calls made during the next animation frame. + * The animation should be implemented via the de-facto requestAnimationFrame + * utility, or inside recursive `setTimeout`s. `setInterval` at this time are not supported. + */ + recordAnimationFrame: function () { + if (this._callWatcher.isRecording()) { + return this._currentAnimationFrameSnapshot.promise; + } + + this._recordingContainsDrawCall = false; + this._callWatcher.eraseRecording(); + this._callWatcher.initTimestampEpoch(); + this._webGLPrimitiveCounter.resetCounts(); + this._callWatcher.resumeRecording(); + + let deferred = this._currentAnimationFrameSnapshot = promise.defer(); + return deferred.promise; + }, + + /** + * Cease attempts to record an animation frame. + */ + stopRecordingAnimationFrame: function () { + if (!this._callWatcher.isRecording()) { + return; + } + this._animationStarted = false; + this._callWatcher.pauseRecording(); + this._callWatcher.eraseRecording(); + this._currentAnimationFrameSnapshot.resolve(null); + this._currentAnimationFrameSnapshot = null; + }, + + /** + * Invoked whenever an instrumented function is called, be it on a + * 2d or WebGL context, or an animation generator like requestAnimationFrame. + */ + _onContentFunctionCall: function (functionCall) { + let { window, name, args } = functionCall.details; + + // The function call arguments are required to replay animation frames, + // in order to generate screenshots. However, simply storing references to + // every kind of object is a bad idea, since their properties may change. + // Consider transformation matrices for example, which are typically + // Float32Arrays whose values can easily change across context calls. + // They need to be cloned. + inplaceShallowCloneArrays(args, window); + + // Handle animations generated using requestAnimationFrame + if (CanvasFront.ANIMATION_GENERATORS.has(name)) { + this._handleAnimationFrame(functionCall); + return; + } + // Handle animations generated using setTimeout. While using + // those timers is considered extremely poor practice, they're still widely + // used on the web, especially for old demos; it's nice to support them as well. + if (CanvasFront.LOOP_GENERATORS.has(name)) { + this._handleAnimationFrame(functionCall); + return; + } + if (CanvasFront.DRAW_CALLS.has(name) && this._animationStarted) { + this._handleDrawCall(functionCall); + this._webGLPrimitiveCounter.handleDrawPrimitive(functionCall); + return; + } + }, + + /** + * Handle animations generated using requestAnimationFrame. + */ + _handleAnimationFrame: function (functionCall) { + if (!this._animationStarted) { + this._handleAnimationFrameBegin(); + } + // Check to see if draw calls occurred yet, as it could be future frames, + // like in the scenario where requestAnimationFrame is called to trigger an animation, + // and rAF is at the beginning of the animate loop. + else if (this._animationContainsDrawCall) { + this._handleAnimationFrameEnd(functionCall); + } + }, + + /** + * Called whenever an animation frame rendering begins. + */ + _handleAnimationFrameBegin: function () { + this._callWatcher.eraseRecording(); + this._animationStarted = true; + }, + + /** + * Called whenever an animation frame rendering ends. + */ + _handleAnimationFrameEnd: function () { + // Get a hold of all the function calls made during this animation frame. + // Since only one snapshot can be recorded at a time, erase all the + // previously recorded calls. + let functionCalls = this._callWatcher.pauseRecording(); + this._callWatcher.eraseRecording(); + this._animationContainsDrawCall = false; + + // Since the animation frame finished, get a hold of the (already retrieved) + // canvas pixels to conveniently create a screenshot of the final rendering. + let index = this._lastDrawCallIndex; + let width = this._lastContentCanvasWidth; + let height = this._lastContentCanvasHeight; + let flipped = !!this._lastThumbnailFlipped; // undefined -> false + let pixels = ContextUtils.getPixelStorage()["8bit"]; + let primitiveResult = this._webGLPrimitiveCounter.getCounts(); + let animationFrameEndScreenshot = { + index: index, + width: width, + height: height, + scaling: 1, + flipped: flipped, + pixels: pixels.subarray(0, width * height * 4) + }; + + // Wrap the function calls and screenshot in a FrameSnapshotActor instance, + // which will resolve the promise returned by `recordAnimationFrame`. + let frameSnapshot = new FrameSnapshotActor(this.conn, { + canvas: this._lastDrawCallCanvas, + calls: functionCalls, + screenshot: animationFrameEndScreenshot, + primitive: { + tris: primitiveResult.tris, + vertices: primitiveResult.vertices, + points: primitiveResult.points, + lines: primitiveResult.lines + } + }); + + this._currentAnimationFrameSnapshot.resolve(frameSnapshot); + this._currentAnimationFrameSnapshot = null; + this._animationStarted = false; + }, + + /** + * Invoked whenever a draw call is detected in the animation frame which is + * currently being recorded. + */ + _handleDrawCall: function (functionCall) { + let functionCalls = this._callWatcher.pauseRecording(); + let caller = functionCall.details.caller; + let global = functionCall.details.global; + + let contentCanvas = this._lastDrawCallCanvas = caller.canvas; + let index = this._lastDrawCallIndex = functionCalls.indexOf(functionCall); + let w = this._lastContentCanvasWidth = contentCanvas.width; + let h = this._lastContentCanvasHeight = contentCanvas.height; + + // To keep things fast, generate images of small and fixed dimensions. + let dimensions = CanvasFront.THUMBNAIL_SIZE; + let thumbnail; + + this._animationContainsDrawCall = true; + + // Create a thumbnail on every draw call on the canvas context, to augment + // the respective function call actor with this additional data. + if (global == "WebGLRenderingContext") { + // Check if drawing to a custom framebuffer (when rendering to texture). + // Don't create a thumbnail in this particular case. + let framebufferBinding = caller.getParameter(caller.FRAMEBUFFER_BINDING); + if (framebufferBinding == null) { + thumbnail = ContextUtils.getPixelsForWebGL(caller, 0, 0, w, h, dimensions); + thumbnail.flipped = this._lastThumbnailFlipped = true; + thumbnail.index = index; + } + } else if (global == "CanvasRenderingContext2D") { + thumbnail = ContextUtils.getPixelsFor2D(caller, 0, 0, w, h, dimensions); + thumbnail.flipped = this._lastThumbnailFlipped = false; + thumbnail.index = index; + } + + functionCall._thumbnail = thumbnail; + this._callWatcher.resumeRecording(); + } +}); + +/** + * A collection of methods for manipulating canvas contexts. + */ +var ContextUtils = { + /** + * WebGL contexts are sensitive to how they're queried. Use this function + * to make sure the right context is always retrieved, if available. + * + * @param HTMLCanvasElement canvas + * The canvas element for which to get a WebGL context. + * @param WebGLRenderingContext gl + * The queried WebGL context, or null if unavailable. + */ + getWebGLContext: function (canvas) { + return canvas.getContext("webgl") || + canvas.getContext("experimental-webgl"); + }, + + /** + * Gets a hold of the rendered pixels in the most efficient way possible for + * a canvas with a WebGL context. + * + * @param WebGLRenderingContext gl + * The WebGL context to get a screenshot from. + * @param number srcX [optional] + * The first left pixel that is read from the framebuffer. + * @param number srcY [optional] + * The first top pixel that is read from the framebuffer. + * @param number srcWidth [optional] + * The number of pixels to read on the X axis. + * @param number srcHeight [optional] + * The number of pixels to read on the Y axis. + * @param number dstHeight [optional] + * The desired generated screenshot height. + * @return object + * An objet containing the screenshot's width, height and pixel data, + * represented as an 8-bit array buffer of r, g, b, a values. + */ + getPixelsForWebGL: function (gl, + srcX = 0, srcY = 0, + srcWidth = gl.canvas.width, + srcHeight = gl.canvas.height, + dstHeight = srcHeight) + { + let contentPixels = ContextUtils.getPixelStorage(srcWidth, srcHeight); + let { "8bit": charView, "32bit": intView } = contentPixels; + gl.readPixels(srcX, srcY, srcWidth, srcHeight, gl.RGBA, gl.UNSIGNED_BYTE, charView); + return this.resizePixels(intView, srcWidth, srcHeight, dstHeight); + }, + + /** + * Gets a hold of the rendered pixels in the most efficient way possible for + * a canvas with a 2D context. + * + * @param CanvasRenderingContext2D ctx + * The 2D context to get a screenshot from. + * @param number srcX [optional] + * The first left pixel that is read from the canvas. + * @param number srcY [optional] + * The first top pixel that is read from the canvas. + * @param number srcWidth [optional] + * The number of pixels to read on the X axis. + * @param number srcHeight [optional] + * The number of pixels to read on the Y axis. + * @param number dstHeight [optional] + * The desired generated screenshot height. + * @return object + * An objet containing the screenshot's width, height and pixel data, + * represented as an 8-bit array buffer of r, g, b, a values. + */ + getPixelsFor2D: function (ctx, + srcX = 0, srcY = 0, + srcWidth = ctx.canvas.width, + srcHeight = ctx.canvas.height, + dstHeight = srcHeight) + { + let { data } = ctx.getImageData(srcX, srcY, srcWidth, srcHeight); + let { "32bit": intView } = ContextUtils.usePixelStorage(data.buffer); + return this.resizePixels(intView, srcWidth, srcHeight, dstHeight); + }, + + /** + * Resizes the provided pixels to fit inside a rectangle with the specified + * height and the same aspect ratio as the source. + * + * @param Uint32Array srcPixels + * The source pixel data, assuming 32bit/pixel and 4 color components. + * @param number srcWidth + * The source pixel data width. + * @param number srcHeight + * The source pixel data height. + * @param number dstHeight [optional] + * The desired resized pixel data height. + * @return object + * An objet containing the resized pixels width, height and data, + * represented as an 8-bit array buffer of r, g, b, a values. + */ + resizePixels: function (srcPixels, srcWidth, srcHeight, dstHeight) { + let screenshotRatio = dstHeight / srcHeight; + let dstWidth = (srcWidth * screenshotRatio) | 0; + let dstPixels = new Uint32Array(dstWidth * dstHeight); + + // If the resized image ends up being completely transparent, returning + // an empty array will skip some redundant serialization cycles. + let isTransparent = true; + + for (let dstX = 0; dstX < dstWidth; dstX++) { + for (let dstY = 0; dstY < dstHeight; dstY++) { + let srcX = (dstX / screenshotRatio) | 0; + let srcY = (dstY / screenshotRatio) | 0; + let cPos = srcX + srcWidth * srcY; + let dPos = dstX + dstWidth * dstY; + let color = dstPixels[dPos] = srcPixels[cPos]; + if (color) { + isTransparent = false; + } + } + } + + return { + width: dstWidth, + height: dstHeight, + pixels: isTransparent ? [] : new Uint8Array(dstPixels.buffer) + }; + }, + + /** + * Invokes a series of canvas context calls, to "replay" an animation frame + * and generate a screenshot. + * + * In case of a WebGL context, an offscreen framebuffer is created for + * the respective canvas, and the rendering will be performed into it. + * This is necessary because some state (like shaders, textures etc.) can't + * be shared between two different WebGL contexts. + * - Hopefully, once SharedResources are a thing this won't be necessary: + * http://www.khronos.org/webgl/wiki/SharedResouces + * - Alternatively, we could pursue the idea of using the same context + * for multiple canvases, instead of trying to share resources: + * https://www.khronos.org/webgl/public-mailing-list/archives/1210/msg00058.html + * + * In case of a 2D context, a new canvas is created, since there's no + * intrinsic state that can't be easily duplicated. + * + * @param number contexType + * The type of context to use. See the CallWatcherFront scope types. + * @param HTMLCanvasElement canvas + * The canvas element which is the source of all context calls. + * @param array calls + * An array of function call actors. + * @param number first + * The first function call to start from. + * @param number last + * The last (inclusive) function call to end at. + * @return object + * The context on which the specified calls were invoked, the + * last registered draw call's index and a cleanup function, which + * needs to be called whenever any potential followup work is finished. + */ + replayAnimationFrame: function ({ contextType, canvas, calls, first, last }) { + let w = canvas.width; + let h = canvas.height; + + let replayContext; + let replayContextScaling; + let customViewport; + let customFramebuffer; + let lastDrawCallIndex = -1; + let doCleanup = () => {}; + + // In case of WebGL contexts, rendering will be done offscreen, in a + // custom framebuffer, but using the same provided context. This is + // necessary because it's very memory-unfriendly to rebuild all the + // required GL state (like recompiling shaders, setting global flags, etc.) + // in an entirely new canvas. However, special care is needed to not + // permanently affect the existing GL state in the process. + if (contextType == "WebGLRenderingContext") { + // To keep things fast, replay the context calls on a framebuffer + // of smaller dimensions than the actual canvas (maximum 256x256 pixels). + let scaling = Math.min(CanvasFront.WEBGL_SCREENSHOT_MAX_HEIGHT, h) / h; + replayContextScaling = scaling; + w = (w * scaling) | 0; + h = (h * scaling) | 0; + + // Fetch the same WebGL context and bind a new framebuffer. + let gl = replayContext = this.getWebGLContext(canvas); + let { newFramebuffer, oldFramebuffer } = this.createBoundFramebuffer(gl, w, h); + customFramebuffer = newFramebuffer; + + // Set the viewport to match the new framebuffer's dimensions. + let { newViewport, oldViewport } = this.setCustomViewport(gl, w, h); + customViewport = newViewport; + + // Revert the framebuffer and viewport to the original values. + doCleanup = () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, oldFramebuffer); + gl.viewport.apply(gl, oldViewport); + }; + } + // In case of 2D contexts, draw everything on a separate canvas context. + else if (contextType == "CanvasRenderingContext2D") { + let contentDocument = canvas.ownerDocument; + let replayCanvas = contentDocument.createElement("canvas"); + replayCanvas.width = w; + replayCanvas.height = h; + replayContext = replayCanvas.getContext("2d"); + replayContextScaling = 1; + customViewport = [0, 0, w, h]; + } + + // Replay all the context calls up to and including the specified one. + for (let i = first; i <= last; i++) { + let { type, name, args } = calls[i].details; + + // Prevent WebGL context calls that try to reset the framebuffer binding + // to the default value, since we want to perform the rendering offscreen. + if (name == "bindFramebuffer" && args[1] == null) { + replayContext.bindFramebuffer(replayContext.FRAMEBUFFER, customFramebuffer); + continue; + } + // Also prevent WebGL context calls that try to change the viewport + // while our custom framebuffer is bound. + if (name == "viewport") { + let framebufferBinding = replayContext.getParameter(replayContext.FRAMEBUFFER_BINDING); + if (framebufferBinding == customFramebuffer) { + replayContext.viewport.apply(replayContext, customViewport); + continue; + } + } + if (type == CallWatcherFront.METHOD_FUNCTION) { + replayContext[name].apply(replayContext, args); + } else if (type == CallWatcherFront.SETTER_FUNCTION) { + replayContext[name] = args; + } + if (CanvasFront.DRAW_CALLS.has(name)) { + lastDrawCallIndex = i; + } + } + + return { + replayContext: replayContext, + replayContextScaling: replayContextScaling, + replayViewport: customViewport, + lastDrawCallIndex: lastDrawCallIndex, + doCleanup: doCleanup + }; + }, + + /** + * Gets an object containing a buffer large enough to hold width * height + * pixels, assuming 32bit/pixel and 4 color components. + * + * This method avoids allocating memory and tries to reuse a common buffer + * as much as possible. + * + * @param number w + * The desired pixel array storage width. + * @param number h + * The desired pixel array storage height. + * @return object + * The requested pixel array buffer. + */ + getPixelStorage: function (w = 0, h = 0) { + let storage = this._currentPixelStorage; + if (storage && storage["32bit"].length >= w * h) { + return storage; + } + return this.usePixelStorage(new ArrayBuffer(w * h * 4)); + }, + + /** + * Creates and saves the array buffer views used by `getPixelStorage`. + * + * @param ArrayBuffer buffer + * The raw buffer used as storage for various array buffer views. + */ + usePixelStorage: function (buffer) { + let array8bit = new Uint8Array(buffer); + let array32bit = new Uint32Array(buffer); + return this._currentPixelStorage = { + "8bit": array8bit, + "32bit": array32bit + }; + }, + + /** + * Creates a framebuffer of the specified dimensions for a WebGL context, + * assuming a RGBA color buffer, a depth buffer and no stencil buffer. + * + * @param WebGLRenderingContext gl + * The WebGL context to create and bind a framebuffer for. + * @param number width + * The desired width of the renderbuffers. + * @param number height + * The desired height of the renderbuffers. + * @return WebGLFramebuffer + * The generated framebuffer object. + */ + createBoundFramebuffer: function (gl, width, height) { + let oldFramebuffer = gl.getParameter(gl.FRAMEBUFFER_BINDING); + let oldRenderbufferBinding = gl.getParameter(gl.RENDERBUFFER_BINDING); + let oldTextureBinding = gl.getParameter(gl.TEXTURE_BINDING_2D); + + let newFramebuffer = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, newFramebuffer); + + // Use a texture as the color renderbuffer attachment, since consumers of + // this function will most likely want to read the rendered pixels back. + let colorBuffer = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, colorBuffer); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + + let depthBuffer = gl.createRenderbuffer(); + gl.bindRenderbuffer(gl.RENDERBUFFER, depthBuffer); + gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, width, height); + + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, colorBuffer, 0); + gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthBuffer); + + gl.bindTexture(gl.TEXTURE_2D, oldTextureBinding); + gl.bindRenderbuffer(gl.RENDERBUFFER, oldRenderbufferBinding); + + return { oldFramebuffer, newFramebuffer }; + }, + + /** + * Sets the viewport of the drawing buffer for a WebGL context. + * @param WebGLRenderingContext gl + * @param number width + * @param number height + */ + setCustomViewport: function (gl, width, height) { + let oldViewport = XPCNativeWrapper.unwrap(gl.getParameter(gl.VIEWPORT)); + let newViewport = [0, 0, width, height]; + gl.viewport.apply(gl, newViewport); + + return { oldViewport, newViewport }; + } +}; + +/** + * Goes through all the arguments and creates a one-level shallow copy + * of all arrays and array buffers. + */ +function inplaceShallowCloneArrays(functionArguments, contentWindow) { + let { Object, Array, ArrayBuffer } = contentWindow; + + functionArguments.forEach((arg, index, store) => { + if (arg instanceof Array) { + store[index] = arg.slice(); + } + if (arg instanceof Object && arg.buffer instanceof ArrayBuffer) { + store[index] = new arg.constructor(arg); + } + }); +} diff --git a/devtools/server/actors/child-process.js b/devtools/server/actors/child-process.js new file mode 100644 index 000000000..7b0e2eaf8 --- /dev/null +++ b/devtools/server/actors/child-process.js @@ -0,0 +1,146 @@ +/* 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 { Cc, Ci, Cu } = require("chrome"); + +const { ChromeDebuggerActor } = require("devtools/server/actors/script"); +const { WebConsoleActor } = require("devtools/server/actors/webconsole"); +const makeDebugger = require("devtools/server/actors/utils/make-debugger"); +const { ActorPool } = require("devtools/server/main"); +const Services = require("Services"); +const { assert } = require("devtools/shared/DevToolsUtils"); +const { TabSources } = require("./utils/TabSources"); + +loader.lazyRequireGetter(this, "WorkerActorList", "devtools/server/actors/worker", true); + +function ChildProcessActor(aConnection) { + this.conn = aConnection; + this._contextPool = new ActorPool(this.conn); + this.conn.addActorPool(this._contextPool); + this.threadActor = null; + + // Use a see-everything debugger + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: dbg => dbg.findAllGlobals(), + shouldAddNewGlobalAsDebuggee: global => true + }); + + // Scope into which the webconsole executes: + // An empty sandbox with chrome privileges + let systemPrincipal = Cc["@mozilla.org/systemprincipal;1"] + .createInstance(Ci.nsIPrincipal); + let sandbox = Cu.Sandbox(systemPrincipal); + this._consoleScope = sandbox; + + this._workerList = null; + this._workerActorPool = null; + this._onWorkerListChanged = this._onWorkerListChanged.bind(this); +} +exports.ChildProcessActor = ChildProcessActor; + +ChildProcessActor.prototype = { + actorPrefix: "process", + + get isRootActor() { + return true; + }, + + get exited() { + return !this._contextPool; + }, + + get url() { + return undefined; + }, + + get window() { + return this._consoleScope; + }, + + get sources() { + if (!this._sources) { + assert(this.threadActor, "threadActor should exist when creating sources."); + this._sources = new TabSources(this.threadActor); + } + return this._sources; + }, + + form: function () { + if (!this._consoleActor) { + this._consoleActor = new WebConsoleActor(this.conn, this); + this._contextPool.addActor(this._consoleActor); + } + + if (!this.threadActor) { + this.threadActor = new ChromeDebuggerActor(this.conn, this); + this._contextPool.addActor(this.threadActor); + } + + return { + actor: this.actorID, + name: "Content process", + + consoleActor: this._consoleActor.actorID, + chromeDebugger: this.threadActor.actorID, + + traits: { + highlightable: false, + networkMonitor: false, + }, + }; + }, + + onListWorkers: function () { + if (!this._workerList) { + this._workerList = new WorkerActorList(this.conn, {}); + } + return this._workerList.getList().then(actors => { + let pool = new ActorPool(this.conn); + for (let actor of actors) { + pool.addActor(actor); + } + + this.conn.removeActorPool(this._workerActorPool); + this._workerActorPool = pool; + this.conn.addActorPool(this._workerActorPool); + + this._workerList.onListChanged = this._onWorkerListChanged; + + return { + "from": this.actorID, + "workers": actors.map(actor => actor.form()) + }; + }); + }, + + _onWorkerListChanged: function () { + this.conn.send({ from: this.actorID, type: "workerListChanged" }); + this._workerList.onListChanged = null; + }, + + disconnect: function () { + this.conn.removeActorPool(this._contextPool); + this._contextPool = null; + + // Tell the live lists we aren't watching any more. + if (this._workerList) { + this._workerList.onListChanged = null; + } + }, + + preNest: function () { + // TODO: freeze windows + // window mediator doesn't work in child. + // it doesn't throw, but doesn't return any window + }, + + postNest: function () { + }, +}; + +ChildProcessActor.prototype.requestTypes = { + "listWorkers": ChildProcessActor.prototype.onListWorkers, +}; diff --git a/devtools/server/actors/childtab.js b/devtools/server/actors/childtab.js new file mode 100644 index 000000000..96d82e281 --- /dev/null +++ b/devtools/server/actors/childtab.js @@ -0,0 +1,82 @@ +/* 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 { Cr } = require("chrome"); +var { TabActor } = require("devtools/server/actors/webbrowser"); + +/** + * Tab actor for documents living in a child process. + * + * Depends on TabActor, defined in webbrowser.js. + */ + +/** + * Creates a tab actor for handling requests to the single tab, like + * attaching and detaching. ContentActor respects the actor factories + * registered with DebuggerServer.addTabActor. + * + * @param connection DebuggerServerConnection + * The conection to the client. + * @param chromeGlobal + * The content script global holding |content| and |docShell| properties for a tab. + * @param prefix + * the prefix used in protocol to create IDs for each actor. + * Used as ID identifying this particular TabActor from the parent process. + */ +function ContentActor(connection, chromeGlobal, prefix) +{ + this._chromeGlobal = chromeGlobal; + this._prefix = prefix; + TabActor.call(this, connection, chromeGlobal); + this.traits.reconfigure = false; + this._sendForm = this._sendForm.bind(this); + this._chromeGlobal.addMessageListener("debug:form", this._sendForm); + + Object.defineProperty(this, "docShell", { + value: this._chromeGlobal.docShell, + configurable: true + }); +} + +ContentActor.prototype = Object.create(TabActor.prototype); + +ContentActor.prototype.constructor = ContentActor; + +Object.defineProperty(ContentActor.prototype, "title", { + get: function () { + return this.window.document.title; + }, + enumerable: true, + configurable: true +}); + +ContentActor.prototype.exit = function () { + if (this._sendForm) { + try { + this._chromeGlobal.removeMessageListener("debug:form", this._sendForm); + } catch (e) { + if (e.result != Cr.NS_ERROR_NULL_POINTER) { + throw e; + } + // In some cases, especially when using messageManagers in non-e10s mode, we reach + // this point with a dead messageManager which only throws errors but does not + // seem to indicate in any other way that it is dead. + } + this._sendForm = null; + } + + TabActor.prototype.exit.call(this); + + this._chromeGlobal = null; +}; + +/** + * On navigation events, our URL and/or title may change, so we update our + * counterpart in the parent process that participates in the tab list. + */ +ContentActor.prototype._sendForm = function () { + this._chromeGlobal.sendAsyncMessage("debug:form", this.form()); +}; diff --git a/devtools/server/actors/chrome.js b/devtools/server/actors/chrome.js new file mode 100644 index 000000000..07cd2ad99 --- /dev/null +++ b/devtools/server/actors/chrome.js @@ -0,0 +1,185 @@ +/* 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 Services = require("Services"); +const { DebuggerServer } = require("../main"); +const { getChildDocShells, TabActor } = require("./webbrowser"); +const makeDebugger = require("./utils/make-debugger"); + +/** + * Creates a TabActor for debugging all the chrome content in the + * current process. Most of the implementation is inherited from TabActor. + * ChromeActor is a child of RootActor, it can be instanciated via + * RootActor.getProcess request. + * ChromeActor exposes all tab actors via its form() request, like TabActor. + * + * History lecture: + * All tab actors used to also be registered as global actors, + * so that the root actor was also exposing tab actors for the main process. + * Tab actors ended up having RootActor as parent actor, + * but more and more features of the tab actors were relying on TabActor. + * So we are now exposing a process actor that offers the same API as TabActor + * by inheriting its functionality. + * Global actors are now only the actors that are meant to be global, + * and are no longer related to any specific scope/document. + * + * @param aConnection DebuggerServerConnection + * The connection to the client. + */ +function ChromeActor(aConnection) { + TabActor.call(this, aConnection); + + // This creates a Debugger instance for chrome debugging all globals. + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: dbg => dbg.findAllGlobals(), + shouldAddNewGlobalAsDebuggee: () => true + }); + + // Ensure catching the creation of any new content docshell + this.listenForNewDocShells = true; + + // Defines the default docshell selected for the tab actor + let window = Services.wm.getMostRecentWindow(DebuggerServer.chromeWindowType); + + // Default to any available top level window if there is no expected window + // (for example when we open firefox with -webide argument) + if (!window) { + window = Services.wm.getMostRecentWindow(null); + } + // On xpcshell, there is no window/docshell + let docShell = window ? window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + : null; + Object.defineProperty(this, "docShell", { + value: docShell, + configurable: true + }); +} +exports.ChromeActor = ChromeActor; + +ChromeActor.prototype = Object.create(TabActor.prototype); + +ChromeActor.prototype.constructor = ChromeActor; + +ChromeActor.prototype.isRootActor = true; + +/** + * Getter for the list of all docshells in this tabActor + * @return {Array} + */ +Object.defineProperty(ChromeActor.prototype, "docShells", { + get: function () { + // Iterate over all top-level windows and all their docshells. + let docShells = []; + let e = Services.ww.getWindowEnumerator(); + while (e.hasMoreElements()) { + let window = e.getNext(); + let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + docShells = docShells.concat(getChildDocShells(docShell)); + } + + return docShells; + } +}); + +ChromeActor.prototype.observe = function (aSubject, aTopic, aData) { + TabActor.prototype.observe.call(this, aSubject, aTopic, aData); + if (!this.attached) { + return; + } + if (aTopic == "chrome-webnavigation-create") { + aSubject.QueryInterface(Ci.nsIDocShell); + this._onDocShellCreated(aSubject); + } else if (aTopic == "chrome-webnavigation-destroy") { + this._onDocShellDestroy(aSubject); + } +}; + +ChromeActor.prototype._attach = function () { + if (this.attached) { + return false; + } + + TabActor.prototype._attach.call(this); + + // Listen for any new/destroyed chrome docshell + Services.obs.addObserver(this, "chrome-webnavigation-create", false); + Services.obs.addObserver(this, "chrome-webnavigation-destroy", false); + + // Iterate over all top-level windows. + let docShells = []; + let e = Services.ww.getWindowEnumerator(); + while (e.hasMoreElements()) { + let window = e.getNext(); + let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + if (docShell == this.docShell) { + continue; + } + this._progressListener.watch(docShell); + } +}; + +ChromeActor.prototype._detach = function () { + if (!this.attached) { + return false; + } + + Services.obs.removeObserver(this, "chrome-webnavigation-create"); + Services.obs.removeObserver(this, "chrome-webnavigation-destroy"); + + // Iterate over all top-level windows. + let docShells = []; + let e = Services.ww.getWindowEnumerator(); + while (e.hasMoreElements()) { + let window = e.getNext(); + let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + if (docShell == this.docShell) { + continue; + } + this._progressListener.unwatch(docShell); + } + + TabActor.prototype._detach.call(this); +}; + +/* ThreadActor hooks. */ + +/** + * Prepare to enter a nested event loop by disabling debuggee events. + */ +ChromeActor.prototype.preNest = function () { + // Disable events in all open windows. + let e = Services.wm.getEnumerator(null); + while (e.hasMoreElements()) { + let win = e.getNext(); + let windowUtils = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + windowUtils.suppressEventHandling(true); + windowUtils.suspendTimeouts(); + } +}; + +/** + * Prepare to exit a nested event loop by enabling debuggee events. + */ +ChromeActor.prototype.postNest = function (aNestData) { + // Enable events in all open windows. + let e = Services.wm.getEnumerator(null); + while (e.hasMoreElements()) { + let win = e.getNext(); + let windowUtils = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + windowUtils.resumeTimeouts(); + windowUtils.suppressEventHandling(false); + } +}; diff --git a/devtools/server/actors/common.js b/devtools/server/actors/common.js new file mode 100644 index 000000000..0177c6749 --- /dev/null +++ b/devtools/server/actors/common.js @@ -0,0 +1,521 @@ +/* -*- 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 { method } = require("devtools/shared/protocol"); + +/** + * Creates "registered" actors factory meant for creating another kind of + * factories, ObservedActorFactory, during the call to listTabs. + * These factories live in DebuggerServer.{tab|global}ActorFactories. + * + * These actors only exposes: + * - `name` string attribute used to match actors by constructor name + * in DebuggerServer.remove{Global,Tab}Actor. + * - `createObservedActorFactory` function to create "observed" actors factory + * + * @param options object, function + * Either an object or a function. + * If given an object: + * + * If given a function (deprecated): + * Constructor function of an actor. + * The constructor function for this actor type. + * This expects to be called as a constructor (i.e. with 'new'), + * and passed two arguments: the DebuggerServerConnection, and + * the BrowserTabActor with which it will be associated. + * Only used for deprecated eagerly loaded actors. + * + */ +function RegisteredActorFactory(options, prefix) { + // By default the actor name will also be used for the actorID prefix. + this._prefix = prefix; + if (typeof (options) != "function") { + // actors definition registered by actorRegistryActor + if (options.constructorFun) { + this._getConstructor = () => options.constructorFun; + } else { + // Lazy actor definition, where options contains all the information + // required to load the actor lazily. + this._getConstructor = function () { + // Load the module + let mod; + try { + mod = require(options.id); + } catch (e) { + throw new Error("Unable to load actor module '" + options.id + "'.\n" + + e.message + "\n" + e.stack + "\n"); + } + // Fetch the actor constructor + let c = mod[options.constructorName]; + if (!c) { + throw new Error("Unable to find actor constructor named '" + + options.constructorName + "'. (Is it exported?)"); + } + return c; + }; + } + // Exposes `name` attribute in order to allow removeXXXActor to match + // the actor by its actor constructor name. + this.name = options.constructorName; + } else { + // Old actor case, where options is a function that is the actor constructor. + this._getConstructor = () => options; + // Exposes `name` attribute in order to allow removeXXXActor to match + // the actor by its actor constructor name. + this.name = options.name; + + // For old actors, we allow the use of a different prefix for actorID + // than for listTabs actor names, by fetching a prefix on the actor prototype. + // (Used by ChromeDebuggerActor) + if (options.prototype && options.prototype.actorPrefix) { + this._prefix = options.prototype.actorPrefix; + } + } +} +RegisteredActorFactory.prototype.createObservedActorFactory = function (conn, parentActor) { + return new ObservedActorFactory(this._getConstructor, this._prefix, conn, parentActor); +}; +exports.RegisteredActorFactory = RegisteredActorFactory; + +/** + * Creates "observed" actors factory meant for creating real actor instances. + * These factories lives in actor pools and fake various actor attributes. + * They will be replaced in actor pools by final actor instances during + * the first request for the same actorID from DebuggerServer._getOrCreateActor. + * + * ObservedActorFactory fakes the following actors attributes: + * actorPrefix (string) Used by ActorPool.addActor to compute the actor id + * actorID (string) Set by ActorPool.addActor just after being instantiated + * registeredPool (object) Set by ActorPool.addActor just after being + * instantiated + * And exposes the following method: + * createActor (function) Instantiate an actor that is going to replace + * this factory in the actor pool. + */ +function ObservedActorFactory(getConstructor, prefix, conn, parentActor) { + this._getConstructor = getConstructor; + this._conn = conn; + this._parentActor = parentActor; + + this.actorPrefix = prefix; + + this.actorID = null; + this.registeredPool = null; +} +ObservedActorFactory.prototype.createActor = function () { + // Fetch the actor constructor + let c = this._getConstructor(); + // Instantiate a new actor instance + let instance = new c(this._conn, this._parentActor); + instance.conn = this._conn; + instance.parentID = this._parentActor.actorID; + // We want the newly-constructed actor to completely replace the factory + // actor. Reusing the existing actor ID will make sure ActorPool.addActor + // does the right thing. + instance.actorID = this.actorID; + this.registeredPool.addActor(instance); + return instance; +}; +exports.ObservedActorFactory = ObservedActorFactory; + + +/** + * Methods shared between RootActor and BrowserTabActor. + */ + +/** + * Populate |this._extraActors| as specified by |aFactories|, reusing whatever + * actors are already there. Add all actors in the final extra actors table to + * |aPool|. + * + * The root actor and the tab actor use this to instantiate actors that other + * parts of the browser have specified with DebuggerServer.addTabActor and + * DebuggerServer.addGlobalActor. + * + * @param aFactories + * An object whose own property names are the names of properties to add to + * some reply packet (say, a tab actor grip or the "listTabs" response + * form), and whose own property values are actor constructor functions, as + * documented for addTabActor and addGlobalActor. + * + * @param this + * The BrowserRootActor or BrowserTabActor with which the new actors will + * be associated. It should support whatever API the |aFactories| + * constructor functions might be interested in, as it is passed to them. + * For the sake of CommonCreateExtraActors itself, it should have at least + * the following properties: + * + * - _extraActors + * An object whose own property names are factory table (and packet) + * property names, and whose values are no-argument actor constructors, + * of the sort that one can add to an ActorPool. + * + * - conn + * The DebuggerServerConnection in which the new actors will participate. + * + * - actorID + * The actor's name, for use as the new actors' parentID. + */ +exports.createExtraActors = function createExtraActors(aFactories, aPool) { + // Walk over global actors added by extensions. + for (let name in aFactories) { + let actor = this._extraActors[name]; + if (!actor) { + // Register another factory, but this time specific to this connection. + // It creates a fake actor that looks like an regular actor in the pool, + // but without actually instantiating the actor. + // It will only be instantiated on the first request made to the actor. + actor = aFactories[name].createObservedActorFactory(this.conn, this); + this._extraActors[name] = actor; + } + + // If the actor already exists in the pool, it may have been instantiated, + // so make sure not to overwrite it by a non-instantiated version. + if (!aPool.has(actor.actorID)) { + aPool.addActor(actor); + } + } +}; + +/** + * Append the extra actors in |this._extraActors|, constructed by a prior call + * to CommonCreateExtraActors, to |aObject|. + * + * @param aObject + * The object to which the extra actors should be added, under the + * property names given in the |aFactories| table passed to + * CommonCreateExtraActors. + * + * @param this + * The BrowserRootActor or BrowserTabActor whose |_extraActors| table we + * should use; see above. + */ +exports.appendExtraActors = function appendExtraActors(aObject) { + for (let name in this._extraActors) { + let actor = this._extraActors[name]; + aObject[name] = actor.actorID; + } +}; + +/** + * Construct an ActorPool. + * + * ActorPools are actorID -> actor mapping and storage. These are + * used to accumulate and quickly dispose of groups of actors that + * share a lifetime. + */ +function ActorPool(aConnection) +{ + this.conn = aConnection; + this._actors = {}; +} + +ActorPool.prototype = { + /** + * Destroy the pool. This will remove all actors from the pool. + */ + destroy: function AP_destroy() { + for (let id in this._actors) { + this.removeActor(this._actors[id]); + } + }, + + /** + * Add an actor to the pool. If the actor doesn't have an ID, allocate one + * from the connection. + * + * @param Object aActor + * The actor to be added to the pool. + */ + addActor: function AP_addActor(aActor) { + aActor.conn = this.conn; + if (!aActor.actorID) { + let prefix = aActor.actorPrefix; + if (!prefix && typeof aActor == "function") { + // typeName is a convention used with protocol.js-based actors + prefix = aActor.prototype.actorPrefix || aActor.prototype.typeName; + } + aActor.actorID = this.conn.allocID(prefix || undefined); + } + + // If the actor is already in a pool, remove it without destroying it. + if (aActor.registeredPool) { + delete aActor.registeredPool._actors[aActor.actorID]; + } + aActor.registeredPool = this; + + this._actors[aActor.actorID] = aActor; + }, + + /** + * Remove an actor from the pool. If the actor has a disconnect method, call + * it. + */ + removeActor: function AP_remove(aActor) { + delete this._actors[aActor.actorID]; + if (aActor.disconnect) { + aActor.disconnect(); + } + }, + + get: function AP_get(aActorID) { + return this._actors[aActorID] || undefined; + }, + + has: function AP_has(aActorID) { + return aActorID in this._actors; + }, + + /** + * Returns true if the pool is empty. + */ + isEmpty: function AP_isEmpty() { + return Object.keys(this._actors).length == 0; + }, + + /** + * Match the api expected by the protocol library. + */ + unmanage: function (aActor) { + return this.removeActor(aActor); + }, + + forEach: function (callback) { + for (let name in this._actors) { + callback(this._actors[name]); + } + }, +}; + +exports.ActorPool = ActorPool; + +/** + * An OriginalLocation represents a location in an original source. + * + * @param SourceActor actor + * A SourceActor representing an original source. + * @param Number line + * A line within the given source. + * @param Number column + * A column within the given line. + * @param String name + * The name of the symbol corresponding to this OriginalLocation. + */ +function OriginalLocation(actor, line, column, name) { + this._connection = actor ? actor.conn : null; + this._actorID = actor ? actor.actorID : undefined; + this._line = line; + this._column = column; + this._name = name; +} + +OriginalLocation.fromGeneratedLocation = function (generatedLocation) { + return new OriginalLocation( + generatedLocation.generatedSourceActor, + generatedLocation.generatedLine, + generatedLocation.generatedColumn + ); +}; + +OriginalLocation.prototype = { + get originalSourceActor() { + return this._connection ? this._connection.getActor(this._actorID) : null; + }, + + get originalUrl() { + let actor = this.originalSourceActor; + let source = actor.source; + return source ? source.url : actor._originalUrl; + }, + + get originalLine() { + return this._line; + }, + + get originalColumn() { + return this._column; + }, + + get originalName() { + return this._name; + }, + + get generatedSourceActor() { + throw new Error("Shouldn't access generatedSourceActor from an OriginalLocation"); + }, + + get generatedLine() { + throw new Error("Shouldn't access generatedLine from an OriginalLocation"); + }, + + get generatedColumn() { + throw new Error("Shouldn't access generatedColumn from an Originallocation"); + }, + + equals: function (other) { + return this.originalSourceActor.url == other.originalSourceActor.url && + this.originalLine === other.originalLine && + (this.originalColumn === undefined || + other.originalColumn === undefined || + this.originalColumn === other.originalColumn); + }, + + toJSON: function () { + return { + source: this.originalSourceActor.form(), + line: this.originalLine, + column: this.originalColumn + }; + } +}; + +exports.OriginalLocation = OriginalLocation; + +/** + * A GeneratedLocation represents a location in a generated source. + * + * @param SourceActor actor + * A SourceActor representing a generated source. + * @param Number line + * A line within the given source. + * @param Number column + * A column within the given line. + */ +function GeneratedLocation(actor, line, column, lastColumn) { + this._connection = actor ? actor.conn : null; + this._actorID = actor ? actor.actorID : undefined; + this._line = line; + this._column = column; + this._lastColumn = (lastColumn !== undefined) ? lastColumn : column + 1; +} + +GeneratedLocation.fromOriginalLocation = function (originalLocation) { + return new GeneratedLocation( + originalLocation.originalSourceActor, + originalLocation.originalLine, + originalLocation.originalColumn + ); +}; + +GeneratedLocation.prototype = { + get originalSourceActor() { + throw new Error(); + }, + + get originalUrl() { + throw new Error("Shouldn't access originalUrl from a GeneratedLocation"); + }, + + get originalLine() { + throw new Error("Shouldn't access originalLine from a GeneratedLocation"); + }, + + get originalColumn() { + throw new Error("Shouldn't access originalColumn from a GeneratedLocation"); + }, + + get originalName() { + throw new Error("Shouldn't access originalName from a GeneratedLocation"); + }, + + get generatedSourceActor() { + return this._connection ? this._connection.getActor(this._actorID) : null; + }, + + get generatedLine() { + return this._line; + }, + + get generatedColumn() { + return this._column; + }, + + get generatedLastColumn() { + return this._lastColumn; + }, + + equals: function (other) { + return this.generatedSourceActor.url == other.generatedSourceActor.url && + this.generatedLine === other.generatedLine && + (this.generatedColumn === undefined || + other.generatedColumn === undefined || + this.generatedColumn === other.generatedColumn); + }, + + toJSON: function () { + return { + source: this.generatedSourceActor.form(), + line: this.generatedLine, + column: this.generatedColumn, + lastColumn: this.generatedLastColumn + }; + } +}; + +exports.GeneratedLocation = GeneratedLocation; + +/** + * A method decorator that ensures the actor is in the expected state before + * proceeding. If the actor is not in the expected state, the decorated method + * returns a rejected promise. + * + * The actor's state must be at this.state property. + * + * @param String expectedState + * The expected state. + * @param String activity + * Additional info about what's going on. + * @param Function method + * The actor method to proceed with when the actor is in the expected + * state. + * + * @returns Function + * The decorated method. + */ +function expectState(expectedState, method, activity) { + return function (...args) { + if (this.state !== expectedState) { + const msg = `Wrong state while ${activity}:` + + `Expected '${expectedState}', ` + + `but current state is '${this.state}'.`; + return promise.reject(new Error(msg)); + } + + return method.apply(this, args); + }; +} + +exports.expectState = expectState; + +/** + * Proxies a call from an actor to an underlying module, stored + * as `bridge` on the actor. This allows a module to be defined in one + * place, usable by other modules/actors on the server, but a separate + * module defining the actor/RDP definition. + * + * @see Framerate implementation: devtools/server/performance/framerate.js + * @see Framerate actor definition: devtools/server/actors/framerate.js + */ +function actorBridge(methodName, definition = {}) { + return method(function () { + return this.bridge[methodName].apply(this.bridge, arguments); + }, definition); +} +exports.actorBridge = actorBridge; + +/** + * Like `actorBridge`, but without a spec definition, for when the actor is + * created with `ActorClassWithSpec` rather than vanilla `ActorClass`. + */ +function actorBridgeWithSpec (methodName) { + return method(function () { + return this.bridge[methodName].apply(this.bridge, arguments); + }); +} +exports.actorBridgeWithSpec = actorBridgeWithSpec; diff --git a/devtools/server/actors/css-properties.js b/devtools/server/actors/css-properties.js new file mode 100644 index 000000000..d24c133d4 --- /dev/null +++ b/devtools/server/actors/css-properties.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 { Cc, Ci } = require("chrome"); + +loader.lazyGetter(this, "DOMUtils", () => { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); +}); + +const protocol = require("devtools/shared/protocol"); +const { ActorClassWithSpec, Actor } = protocol; +const { cssPropertiesSpec } = require("devtools/shared/specs/css-properties"); +const { CSS_PROPERTIES, CSS_TYPES } = require("devtools/shared/css/properties-db"); +const { cssColors } = require("devtools/shared/css/color-db"); + +exports.CssPropertiesActor = ActorClassWithSpec(cssPropertiesSpec, { + typeName: "cssProperties", + + initialize(conn, parent) { + Actor.prototype.initialize.call(this, conn); + this.parent = parent; + }, + + destroy() { + Actor.prototype.destroy.call(this); + }, + + getCSSDatabase() { + const properties = generateCssProperties(); + const pseudoElements = DOMUtils.getCSSPseudoElementNames(); + + return { properties, pseudoElements }; + } +}); + +/** + * Generate the CSS properties object. Every key is the property name, while + * the values are objects that contain information about that property. + * + * @return {Object} + */ +function generateCssProperties() { + const properties = {}; + const propertyNames = DOMUtils.getCSSPropertyNames(DOMUtils.INCLUDE_ALIASES); + const colors = Object.keys(cssColors); + + propertyNames.forEach(name => { + // Get the list of CSS types this property supports. + let supports = []; + for (let type in CSS_TYPES) { + if (safeCssPropertySupportsType(name, DOMUtils["TYPE_" + type])) { + supports.push(CSS_TYPES[type]); + } + } + + // Don't send colors over RDP, these will be re-attached by the front. + let values = DOMUtils.getCSSValuesForProperty(name); + if (values.includes("aliceblue")) { + values = values.filter(x => !colors.includes(x)); + values.unshift("COLOR"); + } + + let subproperties = DOMUtils.getSubpropertiesForCSSProperty(name); + + // In order to maintain any backwards compatible changes when debugging older + // clients, take the definition from the static CSS properties database, and fill it + // in with the most recent property definition from the server. + const clientDefinition = CSS_PROPERTIES[name] || {}; + const serverDefinition = { + isInherited: DOMUtils.isInheritedProperty(name), + values, + supports, + subproperties, + }; + properties[name] = Object.assign(clientDefinition, serverDefinition); + }); + + return properties; +} +exports.generateCssProperties = generateCssProperties; + +/** + * Test if a CSS is property is known using server-code. + * + * @param {string} name + * @return {Boolean} + */ +function isCssPropertyKnown(name) { + try { + // If the property name is unknown, the cssPropertyIsShorthand + // will throw an exception. But if it is known, no exception will + // be thrown; so we just ignore the return value. + DOMUtils.cssPropertyIsShorthand(name); + return true; + } catch (e) { + return false; + } +} + +exports.isCssPropertyKnown = isCssPropertyKnown; + +/** + * A wrapper for DOMUtils.cssPropertySupportsType that ignores invalid + * properties. + * + * @param {String} name The property name. + * @param {number} type The type tested for support. + * @return {Boolean} Whether the property supports the type. + * If the property is unknown, false is returned. + */ +function safeCssPropertySupportsType(name, type) { + try { + return DOMUtils.cssPropertySupportsType(name, type); + } catch (e) { + return false; + } +} diff --git a/devtools/server/actors/csscoverage.js b/devtools/server/actors/csscoverage.js new file mode 100644 index 000000000..2f700656f --- /dev/null +++ b/devtools/server/actors/csscoverage.js @@ -0,0 +1,726 @@ +/* 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 { Cc, Ci } = require("chrome"); + +const Services = require("Services"); +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); + +const events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const { cssUsageSpec } = require("devtools/shared/specs/csscoverage"); + +loader.lazyGetter(this, "DOMUtils", () => { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); +}); +loader.lazyRequireGetter(this, "stylesheets", "devtools/server/actors/stylesheets"); +loader.lazyRequireGetter(this, "prettifyCSS", "devtools/shared/inspector/css-logic", true); + +const CSSRule = Ci.nsIDOMCSSRule; + +const MAX_UNUSED_RULES = 10000; + +/** + * Allow: let foo = l10n.lookup("csscoverageFoo"); + */ +const l10n = exports.l10n = { + _URI: "chrome://devtools-shared/locale/csscoverage.properties", + lookup: function (msg) { + if (this._stringBundle == null) { + this._stringBundle = Services.strings.createBundle(this._URI); + } + return this._stringBundle.GetStringFromName(msg); + } +}; + +/** + * CSSUsage manages the collection of CSS usage data. + * The core of a CSSUsage is a JSON-able data structure called _knownRules + * which looks like this: + * This records the CSSStyleRules and their usage. + * The format is: + * Map({ + * <CSS-URL>|<START-LINE>|<START-COLUMN>: { + * selectorText: <CSSStyleRule.selectorText>, + * test: <simplify(CSSStyleRule.selectorText)>, + * cssText: <CSSStyleRule.cssText>, + * isUsed: <TRUE|FALSE>, + * presentOn: Set([ <HTML-URL>, ... ]), + * preLoadOn: Set([ <HTML-URL>, ... ]), + * isError: <TRUE|FALSE>, + * } + * }) + * + * For example: + * this._knownRules = Map({ + * "http://eg.com/styles1.css|15|0": { + * selectorText: "p.quote:hover", + * test: "p.quote", + * cssText: "p.quote { color: red; }", + * isUsed: true, + * presentOn: Set([ "http://eg.com/page1.html", ... ]), + * preLoadOn: Set([ "http://eg.com/page1.html" ]), + * isError: false, + * }, ... + * }); + */ +var CSSUsageActor = protocol.ActorClassWithSpec(cssUsageSpec, { + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + + this._tabActor = tabActor; + this._running = false; + + this._onTabLoad = this._onTabLoad.bind(this); + this._onChange = this._onChange.bind(this); + + this._notifyOn = Ci.nsIWebProgress.NOTIFY_STATUS | + Ci.nsIWebProgress.NOTIFY_STATE_ALL; + }, + + destroy: function () { + this._tabActor = undefined; + + delete this._onTabLoad; + delete this._onChange; + + protocol.Actor.prototype.destroy.call(this); + }, + + /** + * Begin recording usage data + * @param noreload It's best if we start by reloading the current page + * because that starts the test at a known point, but there could be reasons + * why we don't want to do that (e.g. the page contains state that will be + * lost across a reload) + */ + start: function (noreload) { + if (this._running) { + throw new Error(l10n.lookup("csscoverageRunningError")); + } + + this._isOneShot = false; + this._visitedPages = new Set(); + this._knownRules = new Map(); + this._running = true; + this._tooManyUnused = false; + + this._progressListener = { + QueryInterface: XPCOMUtils.generateQI([ Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference ]), + + onStateChange: (progress, request, flags, status) => { + let isStop = flags & Ci.nsIWebProgressListener.STATE_STOP; + let isWindow = flags & Ci.nsIWebProgressListener.STATE_IS_WINDOW; + + if (isStop && isWindow) { + this._onTabLoad(progress.DOMWindow.document); + } + }, + + onLocationChange: () => {}, + onProgressChange: () => {}, + onSecurityChange: () => {}, + onStatusChange: () => {}, + destroy: () => {} + }; + + this._progress = this._tabActor.docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + this._progress.addProgressListener(this._progressListener, this._notifyOn); + + if (noreload) { + // If we're not starting by reloading the page, then pretend that onload + // has just happened. + this._onTabLoad(this._tabActor.window.document); + } else { + this._tabActor.window.location.reload(); + } + + events.emit(this, "state-change", { isRunning: true }); + }, + + /** + * Cease recording usage data + */ + stop: function () { + if (!this._running) { + throw new Error(l10n.lookup("csscoverageNotRunningError")); + } + + this._progress.removeProgressListener(this._progressListener, this._notifyOn); + this._progress = undefined; + + this._running = false; + events.emit(this, "state-change", { isRunning: false }); + }, + + /** + * Start/stop recording usage data depending on what we're currently doing. + */ + toggle: function () { + return this._running ? this.stop() : this.start(); + }, + + /** + * Running start() quickly followed by stop() does a bunch of unnecessary + * work, so this cuts all that out + */ + oneshot: function () { + if (this._running) { + throw new Error(l10n.lookup("csscoverageRunningError")); + } + + this._isOneShot = true; + this._visitedPages = new Set(); + this._knownRules = new Map(); + + this._populateKnownRules(this._tabActor.window.document); + this._updateUsage(this._tabActor.window.document, false); + }, + + /** + * Called by the ProgressListener to simulate a "load" event + */ + _onTabLoad: function (document) { + this._populateKnownRules(document); + this._updateUsage(document, true); + + this._observeMutations(document); + }, + + /** + * Setup a MutationObserver on the current document + */ + _observeMutations: function (document) { + let MutationObserver = document.defaultView.MutationObserver; + let observer = new MutationObserver(mutations => { + // It's possible that one of the mutations in this list adds a 'use' of + // a CSS rule, and another takes it away. See Bug 1010189 + this._onChange(document); + }); + + observer.observe(document, { + attributes: true, + childList: true, + characterData: false, + subtree: true + }); + }, + + /** + * Event handler for whenever we think the page has changed in a way that + * means the CSS usage might have changed. + */ + _onChange: function (document) { + // Ignore changes pre 'load' + if (!this._visitedPages.has(getURL(document))) { + return; + } + this._updateUsage(document, false); + }, + + /** + * Called whenever we think the list of stylesheets might have changed so + * we can update the list of rules that we should be checking + */ + _populateKnownRules: function (document) { + let url = getURL(document); + this._visitedPages.add(url); + // Go through all the rules in the current sheets adding them to knownRules + // if needed and adding the current url to the list of pages they're on + for (let rule of getAllSelectorRules(document)) { + let ruleId = ruleToId(rule); + let ruleData = this._knownRules.get(ruleId); + if (ruleData == null) { + ruleData = { + selectorText: rule.selectorText, + cssText: rule.cssText, + test: getTestSelector(rule.selectorText), + isUsed: false, + presentOn: new Set(), + preLoadOn: new Set(), + isError: false + }; + this._knownRules.set(ruleId, ruleData); + } + + ruleData.presentOn.add(url); + } + }, + + /** + * Update knownRules with usage information from the current page + */ + _updateUsage: function (document, isLoad) { + let qsaCount = 0; + + // Update this._data with matches to say 'used at load time' by sheet X + let url = getURL(document); + + for (let [ , ruleData ] of this._knownRules) { + // If it broke before, don't try again selectors don't change + if (ruleData.isError) { + continue; + } + + // If it's used somewhere already, don't bother checking again unless + // this is a load event in which case we need to add preLoadOn + if (!isLoad && ruleData.isUsed) { + continue; + } + + // Ignore rules that are not present on this page + if (!ruleData.presentOn.has(url)) { + continue; + } + + qsaCount++; + if (qsaCount > MAX_UNUSED_RULES) { + console.error("Too many unused rules on " + url + " "); + this._tooManyUnused = true; + continue; + } + + try { + let match = document.querySelector(ruleData.test); + if (match != null) { + ruleData.isUsed = true; + if (isLoad) { + ruleData.preLoadOn.add(url); + } + } + } catch (ex) { + ruleData.isError = true; + } + } + }, + + /** + * Returns a JSONable structure designed to help marking up the style editor, + * which describes the CSS selector usage. + * Example: + * [ + * { + * selectorText: "p#content", + * usage: "unused|used", + * start: { line: 3, column: 0 }, + * }, + * ... + * ] + */ + createEditorReport: function (url) { + if (this._knownRules == null) { + return { reports: [] }; + } + + let reports = []; + for (let [ruleId, ruleData] of this._knownRules) { + let { url: ruleUrl, line, column } = deconstructRuleId(ruleId); + if (ruleUrl !== url || ruleData.isUsed) { + continue; + } + + let ruleReport = { + selectorText: ruleData.selectorText, + start: { line: line, column: column } + }; + + if (ruleData.end) { + ruleReport.end = ruleData.end; + } + + reports.push(ruleReport); + } + + return { reports: reports }; + }, + + /** + * Compute the stylesheet URL and delegate the report creation to createEditorReport. + * See createEditorReport documentation. + * + * @param {StyleSheetActor} stylesheetActor + * the stylesheet actor for which the coverage report should be generated. + */ + createEditorReportForSheet: function (stylesheetActor) { + let url = sheetToUrl(stylesheetActor.rawSheet); + return this.createEditorReport(url); + }, + + /** + * Returns a JSONable structure designed for the page report which shows + * the recommended changes to a page. + * + * "preload" means that a rule is used before the load event happens, which + * means that the page could by optimized by placing it in a <style> element + * at the top of the page, moving the <link> elements to the bottom. + * + * Example: + * { + * preload: [ + * { + * url: "http://example.org/page1.html", + * shortUrl: "page1.html", + * rules: [ + * { + * url: "http://example.org/style1.css", + * shortUrl: "style1.css", + * start: { line: 3, column: 4 }, + * selectorText: "p#content", + * formattedCssText: "p#content {\n color: red;\n }\n" + * }, + * ... + * ] + * } + * ], + * unused: [ + * { + * url: "http://example.org/style1.css", + * shortUrl: "style1.css", + * rules: [ ... ] + * } + * ] + * } + */ + createPageReport: function () { + if (this._running) { + throw new Error(l10n.lookup("csscoverageRunningError")); + } + + if (this._visitedPages == null) { + throw new Error(l10n.lookup("csscoverageNotRunError")); + } + + if (this._isOneShot) { + throw new Error(l10n.lookup("csscoverageOneShotReportError")); + } + + // Helper function to create a JSONable data structure representing a rule + const ruleToRuleReport = function (rule, ruleData) { + return { + url: rule.url, + shortUrl: rule.url.split("/").slice(-1)[0], + start: { line: rule.line, column: rule.column }, + selectorText: ruleData.selectorText, + formattedCssText: prettifyCSS(ruleData.cssText) + }; + }; + + // A count of each type of rule for the bar chart + let summary = { used: 0, unused: 0, preload: 0 }; + + // Create the set of the unused rules + let unusedMap = new Map(); + for (let [ruleId, ruleData] of this._knownRules) { + let rule = deconstructRuleId(ruleId); + let rules = unusedMap.get(rule.url); + if (rules == null) { + rules = []; + unusedMap.set(rule.url, rules); + } + if (!ruleData.isUsed) { + let ruleReport = ruleToRuleReport(rule, ruleData); + rules.push(ruleReport); + } else { + summary.unused++; + } + } + let unused = []; + for (let [url, rules] of unusedMap) { + unused.push({ + url: url, + shortUrl: url.split("/").slice(-1), + rules: rules + }); + } + + // Create the set of rules that could be pre-loaded + let preload = []; + for (let url of this._visitedPages) { + let page = { + url: url, + shortUrl: url.split("/").slice(-1), + rules: [] + }; + + for (let [ruleId, ruleData] of this._knownRules) { + if (ruleData.preLoadOn.has(url)) { + let rule = deconstructRuleId(ruleId); + let ruleReport = ruleToRuleReport(rule, ruleData); + page.rules.push(ruleReport); + summary.preload++; + } else { + summary.used++; + } + } + + if (page.rules.length > 0) { + preload.push(page); + } + } + + return { + summary: summary, + preload: preload, + unused: unused + }; + }, + + /** + * For testing only. What pages did we visit. + */ + _testOnlyVisitedPages: function () { + return [...this._visitedPages]; + }, +}); + +exports.CSSUsageActor = CSSUsageActor; + +/** + * Generator that filters the CSSRules out of _getAllRules so it only + * iterates over the CSSStyleRules + */ +function* getAllSelectorRules(document) { + for (let rule of getAllRules(document)) { + if (rule.type === CSSRule.STYLE_RULE && rule.selectorText !== "") { + yield rule; + } + } +} + +/** + * Generator to iterate over the CSSRules in all the stylesheets the + * current document (i.e. it includes import rules, media rules, etc) + */ +function* getAllRules(document) { + // sheets is an array of the <link> and <style> element in this document + let sheets = getAllSheets(document); + for (let i = 0; i < sheets.length; i++) { + for (let j = 0; j < sheets[i].cssRules.length; j++) { + yield sheets[i].cssRules[j]; + } + } +} + +/** + * Get an array of all the stylesheets that affect this document. That means + * the <link> and <style> based sheets, and the @imported sheets (recursively) + * but not the sheets in nested frames. + */ +function getAllSheets(document) { + // sheets is an array of the <link> and <style> element in this document + let sheets = Array.slice(document.styleSheets); + // Add @imported sheets + for (let i = 0; i < sheets.length; i++) { + let subSheets = getImportedSheets(sheets[i]); + sheets = sheets.concat(...subSheets); + } + return sheets; +} + +/** + * Recursively find @import rules in the given stylesheet. + * We're relying on the browser giving rule.styleSheet == null to resolve + * @import loops + */ +function getImportedSheets(stylesheet) { + let sheets = []; + for (let i = 0; i < stylesheet.cssRules.length; i++) { + let rule = stylesheet.cssRules[i]; + // rule.styleSheet == null with duplicate @imports for the same URL. + if (rule.type === CSSRule.IMPORT_RULE && rule.styleSheet != null) { + sheets.push(rule.styleSheet); + let subSheets = getImportedSheets(rule.styleSheet); + sheets = sheets.concat(...subSheets); + } + } + return sheets; +} + +/** + * Get a unique identifier for a rule. This is currently the string + * <CSS-URL>|<START-LINE>|<START-COLUMN> + * @see deconstructRuleId(ruleId) + */ +function ruleToId(rule) { + let line = DOMUtils.getRelativeRuleLine(rule); + let column = DOMUtils.getRuleColumn(rule); + return sheetToUrl(rule.parentStyleSheet) + "|" + line + "|" + column; +} + +/** + * Convert a ruleId to an object with { url, line, column } properties + * @see ruleToId(rule) + */ +const deconstructRuleId = exports.deconstructRuleId = function (ruleId) { + let split = ruleId.split("|"); + if (split.length > 3) { + let replace = split.slice(0, split.length - 3 + 1).join("|"); + split.splice(0, split.length - 3 + 1, replace); + } + let [ url, line, column ] = split; + return { + url: url, + line: parseInt(line, 10), + column: parseInt(column, 10) + }; +}; + +/** + * We're only interested in the origin and pathname, because changes to the + * username, password, hash, or query string probably don't significantly + * change the CSS usage properties of a page. + * @param document + */ +const getURL = exports.getURL = function (document) { + let url = new document.defaultView.URL(document.documentURI); + return url == "about:blank" ? "" : "" + url.origin + url.pathname; +}; + +/** + * Pseudo class handling constants: + * We split pseudo-classes into a number of categories so we can decide how we + * should match them. See getTestSelector for how we use these constants. + * + * @see http://dev.w3.org/csswg/selectors4/#overview + * @see https://developer.mozilla.org/en-US/docs/tag/CSS%20Pseudo-class + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements + */ + +/** + * Category 1: Pseudo-classes that depend on external browser/OS state + * This includes things like the time, locale, position of mouse/caret/window, + * contents of browser history, etc. These can be hard to mimic. + * Action: Remove from selectors + */ +const SEL_EXTERNAL = [ + "active", "active-drop", "current", "dir", "focus", "future", "hover", + "invalid-drop", "lang", "past", "placeholder-shown", "target", "valid-drop", + "visited" +]; + +/** + * Category 2: Pseudo-classes that depend on user-input state + * These are pseudo-classes that arguably *should* be covered by unit tests but + * which probably aren't and which are unlikely to be covered by manual tests. + * We're currently stripping them out, + * Action: Remove from selectors (but consider future command line flag to + * enable them in the future. e.g. 'csscoverage start --strict') + */ +const SEL_FORM = [ + "checked", "default", "disabled", "enabled", "fullscreen", "in-range", + "indeterminate", "invalid", "optional", "out-of-range", "required", "valid" +]; + +/** + * Category 3: Pseudo-elements + * querySelectorAll doesn't return matches with pseudo-elements because there + * is no element to match (they're pseudo) so we have to remove them all. + * (See http://codepen.io/joewalker/pen/sanDw for a demo) + * Action: Remove from selectors (including deprecated single colon versions) + */ +const SEL_ELEMENT = [ + "after", "before", "first-letter", "first-line", "selection" +]; + +/** + * Category 4: Structural pseudo-classes + * This is a category defined by the spec (also called tree-structural and + * grid-structural) for selection based on relative position in the document + * tree that cannot be represented by other simple selectors or combinators. + * Action: Require a page-match + */ +const SEL_STRUCTURAL = [ + "empty", "first-child", "first-of-type", "last-child", "last-of-type", + "nth-column", "nth-last-column", "nth-child", "nth-last-child", + "nth-last-of-type", "nth-of-type", "only-child", "only-of-type", "root" +]; + +/** + * Category 4a: Semi-structural pseudo-classes + * These are not structural according to the spec, but act nevertheless on + * information in the document tree. + * Action: Require a page-match + */ +const SEL_SEMI = [ "any-link", "link", "read-only", "read-write", "scope" ]; + +/** + * Category 5: Combining pseudo-classes + * has(), not() etc join selectors together in various ways. We take care when + * removing pseudo-classes to convert "not(:hover)" into "not(*)" and so on. + * With these changes the combining pseudo-classes should probably stand on + * their own. + * Action: Require a page-match + */ +const SEL_COMBINING = [ "not", "has", "matches" ]; + +/** + * Category 6: Media pseudo-classes + * Pseudo-classes that should be ignored because they're only relevant to + * media queries + * Action: Don't need removing from selectors as they appear in media queries + */ +const SEL_MEDIA = [ "blank", "first", "left", "right" ]; + +/** + * A test selector is a reduced form of a selector that we actually test + * against. This code strips out pseudo-elements and some pseudo-classes that + * we think should not have to match in order for the selector to be relevant. + */ +function getTestSelector(selector) { + let replacement = selector; + let replaceSelector = pseudo => { + replacement = replacement.replace(" :" + pseudo, " *") + .replace("(:" + pseudo, "(*") + .replace(":" + pseudo, ""); + }; + + SEL_EXTERNAL.forEach(replaceSelector); + SEL_FORM.forEach(replaceSelector); + SEL_ELEMENT.forEach(replaceSelector); + + // Pseudo elements work in : and :: forms + SEL_ELEMENT.forEach(pseudo => { + replacement = replacement.replace("::" + pseudo, ""); + }); + + return replacement; +} + +/** + * I've documented all known pseudo-classes above for 2 reasons: To allow + * checking logic and what might be missing, but also to allow a unit test + * that fetches the list of supported pseudo-classes and pseudo-elements from + * the platform and check that they were all represented here. + */ +exports.SEL_ALL = [ + SEL_EXTERNAL, SEL_FORM, SEL_ELEMENT, SEL_STRUCTURAL, SEL_SEMI, + SEL_COMBINING, SEL_MEDIA +].reduce(function (prev, curr) { + return prev.concat(curr); +}, []); + +/** + * Find a URL for a given stylesheet + * @param {StyleSheet} stylesheet raw stylesheet + */ +const sheetToUrl = function (stylesheet) { + // For <link> elements + if (stylesheet.href) { + return stylesheet.href; + } + + // For <style> elements + if (stylesheet.ownerNode) { + let document = stylesheet.ownerNode.ownerDocument; + let sheets = [...document.querySelectorAll("style")]; + let index = sheets.indexOf(stylesheet.ownerNode); + return getURL(document) + " → <style> index " + index; + } + + throw new Error("Unknown sheet source"); +}; diff --git a/devtools/server/actors/device.js b/devtools/server/actors/device.js new file mode 100644 index 000000000..bf400913a --- /dev/null +++ b/devtools/server/actors/device.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const {Ci} = require("chrome"); +const Services = require("Services"); +const protocol = require("devtools/shared/protocol"); +const promise = require("promise"); +const {LongStringActor} = require("devtools/server/actors/string"); +const {DebuggerServer} = require("devtools/server/main"); +const {getSystemInfo, getSetting} = require("devtools/shared/system"); +const {deviceSpec} = require("devtools/shared/specs/device"); +const FileReader = require("FileReader"); +const {PermissionsTable} = require("resource://gre/modules/PermissionsTable.jsm"); + +var DeviceActor = exports.DeviceActor = protocol.ActorClassWithSpec(deviceSpec, { + _desc: null, + + getDescription: function () { + return getSystemInfo(); + }, + + getWallpaper: function () { + let deferred = promise.defer(); + getSetting("wallpaper.image").then((blob) => { + let reader = new FileReader(); + let conn = this.conn; + reader.addEventListener("load", function () { + let str = new LongStringActor(conn, reader.result); + deferred.resolve(str); + }); + reader.addEventListener("error", function () { + deferred.reject(reader.error); + }); + reader.readAsDataURL(blob); + }); + return deferred.promise; + }, + + screenshotToDataURL: function () { + let window = Services.wm.getMostRecentWindow(DebuggerServer.chromeWindowType); + var devicePixelRatio = window.devicePixelRatio; + let canvas = window.document.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); + let width = window.innerWidth; + let height = window.innerHeight; + canvas.setAttribute("width", Math.round(width * devicePixelRatio)); + canvas.setAttribute("height", Math.round(height * devicePixelRatio)); + let context = canvas.getContext("2d"); + let flags = + context.DRAWWINDOW_DRAW_CARET | + context.DRAWWINDOW_DRAW_VIEW | + context.DRAWWINDOW_USE_WIDGET_LAYERS; + context.scale(devicePixelRatio, devicePixelRatio); + context.drawWindow(window, 0, 0, width, height, "rgb(255,255,255)", flags); + let dataURL = canvas.toDataURL("image/png"); + return new LongStringActor(this.conn, dataURL); + }, + + getRawPermissionsTable: function () { + return { + rawPermissionsTable: PermissionsTable, + UNKNOWN_ACTION: Ci.nsIPermissionManager.UNKNOWN_ACTION, + ALLOW_ACTION: Ci.nsIPermissionManager.ALLOW_ACTION, + DENY_ACTION: Ci.nsIPermissionManager.DENY_ACTION, + PROMPT_ACTION: Ci.nsIPermissionManager.PROMPT_ACTION + }; + } +}); diff --git a/devtools/server/actors/director-manager.js b/devtools/server/actors/director-manager.js new file mode 100644 index 000000000..027a456db --- /dev/null +++ b/devtools/server/actors/director-manager.js @@ -0,0 +1,615 @@ +/* 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 events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); + +const { Cu, Ci } = require("chrome"); + +const { on, once, off, emit } = events; +const { method, Arg, Option, RetVal, types } = protocol; + +const { sandbox, evaluate } = require("sdk/loader/sandbox"); +const { Class } = require("sdk/core/heritage"); + +const { PlainTextConsole } = require("sdk/console/plain-text"); + +const { DirectorRegistry } = require("./director-registry"); + +const { + messagePortSpec, + directorManagerSpec, + directorScriptSpec, +} = require("devtools/shared/specs/director-manager"); + +/** + * Error Messages + */ + +const ERR_MESSAGEPORT_FINALIZED = "message port finalized"; + +const ERR_DIRECTOR_UNKNOWN_SCRIPTID = "unkown director-script id"; +const ERR_DIRECTOR_UNINSTALLED_SCRIPTID = "uninstalled director-script id"; + +/** + * A MessagePort Actor allowing communication through messageport events + * over the remote debugging protocol. + */ +var MessagePortActor = exports.MessagePortActor = protocol.ActorClassWithSpec(messagePortSpec, { + /** + * Create a MessagePort actor. + * + * @param DebuggerServerConnection conn + * The server connection. + * @param MessagePort port + * The wrapped MessagePort. + */ + initialize: function (conn, port) { + protocol.Actor.prototype.initialize.call(this, conn); + + // NOTE: can't get a weak reference because we need to subscribe events + // using port.onmessage or addEventListener + this.port = port; + }, + + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + this.finalize(); + }, + + /** + * Sends a message on the wrapped message port. + * + * @param Object msg + * The JSON serializable message event payload + */ + postMessage: function (msg) { + if (!this.port) { + console.error(ERR_MESSAGEPORT_FINALIZED); + return; + } + + this.port.postMessage(msg); + }, + + /** + * Starts to receive and send queued messages on this message port. + */ + start: function () { + if (!this.port) { + console.error(ERR_MESSAGEPORT_FINALIZED); + return; + } + + // NOTE: set port.onmessage to a function is an implicit start + // and starts to send queued messages. + // On the client side we should set MessagePortClient.onmessage + // to a setter which register an handler to the message event + // and call the actor start method to start receiving messages + // from the MessagePort's queue. + this.port.onmessage = (evt) => { + var ports; + + // TODO: test these wrapped ports + if (Array.isArray(evt.ports)) { + ports = evt.ports.map((port) => { + let actor = new MessagePortActor(this.conn, port); + this.manage(actor); + return actor; + }); + } + + emit(this, "message", { + isTrusted: evt.isTrusted, + data: evt.data, + origin: evt.origin, + lastEventId: evt.lastEventId, + source: this, + ports: ports + }); + }; + }, + + /** + * Starts to receive and send queued messages on this message port, or + * raise an exception if the port is null + */ + close: function () { + if (!this.port) { + console.error(ERR_MESSAGEPORT_FINALIZED); + return; + } + + try { + this.port.onmessage = null; + this.port.close(); + } catch (e) { + // The port might be a dead object + console.error(e); + } + }, + + finalize: function () { + this.close(); + this.port = null; + }, +}); + +/** + * The Director Script Actor manage javascript code running in a non-privileged sandbox with the same + * privileges of the target global (browser tab or a firefox os app). + * + * After retrieving an instance of this actor (from the tab director actor), you'll need to set it up + * by calling setup(). + * + * After the setup, this actor will automatically attach/detach the content script (and optionally a + * directly connect the debugger client and the content script using a MessageChannel) on tab + * navigation. + */ +var DirectorScriptActor = exports.DirectorScriptActor = protocol.ActorClassWithSpec(directorScriptSpec, { + /** + * Creates the director script actor + * + * @param DebuggerServerConnection conn + * The server connection. + * @param Actor tabActor + * The tab (or root) actor. + * @param String scriptId + * The director-script id. + * @param String scriptCode + * The director-script javascript source. + * @param Object scriptOptions + * The director-script options object. + */ + initialize: function (conn, tabActor, { scriptId, scriptCode, scriptOptions }) { + protocol.Actor.prototype.initialize.call(this, conn, tabActor); + + this.tabActor = tabActor; + + this._scriptId = scriptId; + this._scriptCode = scriptCode; + this._scriptOptions = scriptOptions; + this._setupCalled = false; + + this._onGlobalCreated = this._onGlobalCreated.bind(this); + this._onGlobalDestroyed = this._onGlobalDestroyed.bind(this); + }, + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + + this.finalize(); + }, + + /** + * Starts listening to the tab global created, in order to create the director-script sandbox + * using the configured scriptCode, attached/detached automatically to the tab + * window on tab navigation. + * + * @param Boolean reload + * attach the page immediately or reload it first. + * @param Boolean skipAttach + * skip the attach + */ + setup: function ({ reload, skipAttach }) { + if (this._setupCalled) { + // do nothing + return; + } + + this._setupCalled = true; + + on(this.tabActor, "window-ready", this._onGlobalCreated); + on(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + + // optional skip attach (needed by director-manager for director scripts bulk activation) + if (skipAttach) { + return; + } + + if (reload) { + this.window.location.reload(); + } else { + // fake a global created event to attach without reload + this._onGlobalCreated({ id: getWindowID(this.window), window: this.window, isTopLevel: true }); + } + }, + + /** + * Get the attached MessagePort actor if any + */ + getMessagePort: function () { + return this._messagePortActor; + }, + + /** + * Stop listening for document global changes, destroy the content worker and puts + * this actor to hibernation. + */ + finalize: function () { + if (!this._setupCalled) { + return; + } + + off(this.tabActor, "window-ready", this._onGlobalCreated); + off(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + + this._onGlobalDestroyed({ id: this._lastAttachedWinId }); + + this._setupCalled = false; + }, + + // local helpers + get window() { + return this.tabActor.window; + }, + + /* event handlers */ + _onGlobalCreated: function ({ id, window, isTopLevel }) { + if (!isTopLevel) { + // filter iframes + return; + } + + try { + if (this._lastAttachedWinId) { + // if we have received a global created without a previous global destroyed, + // it's time to cleanup the previous state + this._onGlobalDestroyed(this._lastAttachedWinId); + } + + // TODO: check if we want to share a single sandbox per global + // for multiple debugger clients + + // create & attach the new sandbox + this._scriptSandbox = new DirectorScriptSandbox({ + scriptId: this._scriptId, + scriptCode: this._scriptCode, + scriptOptions: this._scriptOptions + }); + + // attach the global window + this._lastAttachedWinId = id; + var port = this._scriptSandbox.attach(window, id); + this._onDirectorScriptAttach(window, port); + } catch (e) { + this._onDirectorScriptError(e); + } + }, + _onGlobalDestroyed: function ({ id }) { + if (id !== this._lastAttachedWinId) { + // filter destroyed globals + return; + } + + // unmanage and cleanup the messageport actor + if (this._messagePortActor) { + this.unmanage(this._messagePortActor); + this._messagePortActor = null; + } + + // NOTE: destroy here the old worker + if (this._scriptSandbox) { + this._scriptSandbox.destroy(this._onDirectorScriptError.bind(this)); + + // send a detach event to the debugger client + emit(this, "detach", { + directorScriptId: this._scriptId, + innerId: this._lastAttachedWinId + }); + + this._lastAttachedWinId = null; + this._scriptSandbox = null; + } + }, + _onDirectorScriptError: function (error) { + // route the content script error to the debugger client + if (error) { + // prevents silent director-script-errors + console.error("director-script-error", error); + // route errors to debugger server clients + emit(this, "error", { + directorScriptId: this._scriptId, + message: error.toString(), + stack: error.stack, + fileName: error.fileName, + lineNumber: error.lineNumber, + columnNumber: error.columnNumber + }); + } + }, + _onDirectorScriptAttach: function (window, port) { + let portActor = new MessagePortActor(this.conn, port); + this.manage(portActor); + this._messagePortActor = portActor; + + emit(this, "attach", { + directorScriptId: this._scriptId, + url: (window && window.location) ? window.location.toString() : "", + innerId: this._lastAttachedWinId, + port: this._messagePortActor + }); + } +}); + +/** + * The DirectorManager Actor is a tab actor which manages enabling/disabling director scripts. + */ +const DirectorManagerActor = exports.DirectorManagerActor = protocol.ActorClassWithSpec(directorManagerSpec, { + /* init & destroy methods */ + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this._directorScriptActorsMap = new Map(); + }, + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + this.finalize(); + }, + + /** + * Retrieves the list of installed director-scripts. + */ + list: function () { + let enabledScriptIds = [...this._directorScriptActorsMap.keys()]; + + return { + installed: DirectorRegistry.list(), + enabled: enabledScriptIds + }; + }, + + /** + * Bulk enabling director-scripts. + * + * @param Array[String] selectedIds + * The list of director-script ids to be enabled, + * ["*"] will activate all the installed director-scripts + * @param Boolean reload + * optionally reload the target window + */ + enableByScriptIds: function (selectedIds, { reload }) { + if (selectedIds && selectedIds.length === 0) { + // filtered all director scripts ids + return; + } + + for (let scriptId of DirectorRegistry.list()) { + // filter director script ids + if (selectedIds.indexOf("*") < 0 && + selectedIds.indexOf(scriptId) < 0) { + continue; + } + + let actor = this.getByScriptId(scriptId); + + // skip attach if reload is true (activated director scripts + // will be automatically attached on the final reload) + actor.setup({ reload: false, skipAttach: reload }); + } + + if (reload) { + this.tabActor.window.location.reload(); + } + }, + + /** + * Bulk disabling director-scripts. + * + * @param Array[String] selectedIds + * The list of director-script ids to be disable, + * ["*"] will de-activate all the enable director-scripts + * @param Boolean reload + * optionally reload the target window + */ + disableByScriptIds: function (selectedIds, { reload }) { + if (selectedIds && selectedIds.length === 0) { + // filtered all director scripts ids + return; + } + + for (let scriptId of this._directorScriptActorsMap.keys()) { + // filter director script ids + if (selectedIds.indexOf("*") < 0 && + selectedIds.indexOf(scriptId) < 0) { + continue; + } + + let actor = this._directorScriptActorsMap.get(scriptId); + this._directorScriptActorsMap.delete(scriptId); + + // finalize the actor (which will produce director-script-detach event) + actor.finalize(); + // unsubscribe event handlers on the disabled actor + off(actor); + + this.unmanage(actor); + } + + if (reload) { + this.tabActor.window.location.reload(); + } + }, + + /** + * Retrieves the actor instance of an installed director-script + * (and create the actor instance if it doesn't exists yet). + */ + getByScriptId: function (scriptId) { + var id = scriptId; + // raise an unknown director-script id exception + if (!DirectorRegistry.checkInstalled(id)) { + console.error(ERR_DIRECTOR_UNKNOWN_SCRIPTID, id); + throw Error(ERR_DIRECTOR_UNKNOWN_SCRIPTID); + } + + // get a previous created actor instance + let actor = this._directorScriptActorsMap.get(id); + + // create a new actor instance + if (!actor) { + let directorScriptDefinition = DirectorRegistry.get(id); + + // test lazy director-script (e.g. uninstalled in the parent process) + if (!directorScriptDefinition) { + + console.error(ERR_DIRECTOR_UNINSTALLED_SCRIPTID, id); + throw Error(ERR_DIRECTOR_UNINSTALLED_SCRIPTID); + } + + actor = new DirectorScriptActor(this.conn, this.tabActor, directorScriptDefinition); + this._directorScriptActorsMap.set(id, actor); + + on(actor, "error", emit.bind(null, this, "director-script-error")); + on(actor, "attach", emit.bind(null, this, "director-script-attach")); + on(actor, "detach", emit.bind(null, this, "director-script-detach")); + + this.manage(actor); + } + + return actor; + }, + + finalize: function () { + this.disableByScriptIds(["*"], false); + } +}); + +/* private helpers */ + +/** + * DirectorScriptSandbox is a private utility class, which attach a non-priviliged sandbox + * to a target window. + */ +const DirectorScriptSandbox = Class({ + initialize: function ({scriptId, scriptCode, scriptOptions}) { + this._scriptId = scriptId; + this._scriptCode = scriptCode; + this._scriptOptions = scriptOptions; + }, + + attach: function (window, innerId) { + this._innerId = innerId, + this._window = window; + this._proto = Cu.createObjectIn(this._window); + + var id = this._scriptId; + var uri = this._scriptCode; + + this._sandbox = sandbox(window, { + sandboxName: uri, + sandboxPrototype: this._proto, + sameZoneAs: window, + wantXrays: true, + wantComponents: false, + wantExportHelpers: false, + metadata: { + URI: uri, + addonID: id, + SDKDirectorScript: true, + "inner-window-id": innerId + } + }); + + // create a CommonJS module object which match the interface from addon-sdk + // (addon-sdk/sources/lib/toolkit/loader.js#L678-L686) + var module = Cu.cloneInto(Object.create(null, { + id: { enumerable: true, value: id }, + uri: { enumerable: true, value: uri }, + exports: { enumerable: true, value: Cu.createObjectIn(this._sandbox) } + }), this._sandbox); + + // create a console API object + let directorScriptConsole = new PlainTextConsole(null, this._innerId); + + // inject CommonJS module globals into the sandbox prototype + Object.defineProperties(this._proto, { + module: { enumerable: true, value: module }, + exports: { enumerable: true, value: module.exports }, + console: { + enumerable: true, + value: Cu.cloneInto(directorScriptConsole, this._sandbox, { cloneFunctions: true }) + } + }); + + Object.defineProperties(this._sandbox, { + require: { + enumerable: true, + value: Cu.cloneInto(function () { + throw Error("NOT IMPLEMENTED"); + }, this._sandbox, { cloneFunctions: true }) + } + }); + + // TODO: if the debugger target is local, the debugger client could pass + // to the director actor the resource url instead of the entire javascript source code. + + // evaluate the director script source in the sandbox + evaluate(this._sandbox, this._scriptCode, "javascript:" + this._scriptCode); + + // prepare the messageport connected to the debugger client + let { port1, port2 } = new this._window.MessageChannel(); + + // prepare the unload callbacks queue + var sandboxOnUnloadQueue = this._sandboxOnUnloadQueue = []; + + // create the attach options + var attachOptions = this._attachOptions = Cu.createObjectIn(this._sandbox); + Object.defineProperties(attachOptions, { + port: { enumerable: true, value: port1 }, + window: { enumerable: true, value: window }, + scriptOptions: { enumerable: true, value: Cu.cloneInto(this._scriptOptions, this._sandbox) }, + onUnload: { + enumerable: true, + value: Cu.cloneInto(function (cb) { + // collect unload callbacks + if (typeof cb == "function") { + sandboxOnUnloadQueue.push(cb); + } + }, this._sandbox, { cloneFunctions: true }) + } + }); + + // select the attach method + var exports = this._proto.module.exports; + if (this._scriptOptions && "attachMethod" in this._scriptOptions) { + this._sandboxOnAttach = exports[this._scriptOptions.attachMethod]; + } else { + this._sandboxOnAttach = exports; + } + + if (typeof this._sandboxOnAttach !== "function") { + throw Error("the configured attachMethod '" + + (this._scriptOptions.attachMethod || "module.exports") + + "' is not exported by the directorScript"); + } + + // call the attach method + this._sandboxOnAttach.call(this._sandbox, attachOptions); + + return port2; + }, + destroy: function (onError) { + // evaluate queue unload methods if any + while (this._sandboxOnUnloadQueue && this._sandboxOnUnloadQueue.length > 0) { + let cb = this._sandboxOnUnloadQueue.pop(); + + try { + cb(); + } catch (e) { + console.error("Exception on DirectorScript Sandbox destroy", e); + onError(e); + } + } + + Cu.nukeSandbox(this._sandbox); + } +}); + +function getWindowID(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .currentInnerWindowID; +} diff --git a/devtools/server/actors/director-registry.js b/devtools/server/actors/director-registry.js new file mode 100644 index 000000000..54584bcde --- /dev/null +++ b/devtools/server/actors/director-registry.js @@ -0,0 +1,254 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; 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 protocol = require("devtools/shared/protocol"); + +const {DebuggerServer} = require("devtools/server/main"); + +const {directorRegistrySpec} = require("devtools/shared/specs/director-registry"); + +/** + * Error Messages + */ + +const ERR_DIRECTOR_INSTALL_TWICE = "Trying to install a director-script twice"; +const ERR_DIRECTOR_INSTALL_EMPTY = "Trying to install an empty director-script"; +const ERR_DIRECTOR_UNINSTALL_UNKNOWN = "Trying to uninstall an unkown director-script"; + +const ERR_DIRECTOR_PARENT_UNKNOWN_METHOD = "Unknown parent process method"; +const ERR_DIRECTOR_CHILD_NOTIMPLEMENTED_METHOD = "Unexpected call to notImplemented method"; +const ERR_DIRECTOR_CHILD_MULTIPLE_REPLIES = "Unexpected multiple replies to called parent method"; +const ERR_DIRECTOR_CHILD_NO_REPLY = "Unexpected no reply to called parent method"; + +/** + * Director Registry + */ + +// Map of director scripts ids to director script definitions +var gDirectorScripts = Object.create(null); + +const DirectorRegistry = exports.DirectorRegistry = { + /** + * Register a Director Script with the debugger server. + * @param id string + * The ID of a director script. + * @param directorScriptDef object + * The definition of a director script. + */ + install: function (id, scriptDef) { + if (id in gDirectorScripts) { + console.error(ERR_DIRECTOR_INSTALL_TWICE, id); + return false; + } + + if (!scriptDef) { + console.error(ERR_DIRECTOR_INSTALL_EMPTY, id); + return false; + } + + gDirectorScripts[id] = scriptDef; + + return true; + }, + + /** + * Unregister a Director Script with the debugger server. + * @param id string + * The ID of a director script. + */ + uninstall: function (id) { + if (id in gDirectorScripts) { + delete gDirectorScripts[id]; + + return true; + } + + console.error(ERR_DIRECTOR_UNINSTALL_UNKNOWN, id); + + return false; + }, + + /** + * Returns true if a director script id has been registered. + * @param id string + * The ID of a director script. + */ + checkInstalled: function (id) { + return (this.list().indexOf(id) >= 0); + }, + + /** + * Returns a registered director script definition by id. + * @param id string + * The ID of a director script. + */ + get: function (id) { + return gDirectorScripts[id]; + }, + + /** + * Returns an array of registered director script ids. + */ + list: function () { + return Object.keys(gDirectorScripts); + }, + + /** + * Removes all the registered director scripts. + */ + clear: function () { + gDirectorScripts = Object.create(null); + } +}; + +/** + * E10S parent/child setup helpers + */ + +exports.setupParentProcess = function setupParentProcess({ mm, prefix }) { + // listen for director-script requests from the child process + setMessageManager(mm); + + /* parent process helpers */ + + function handleChildRequest(msg) { + switch (msg.json.method) { + case "get": + return DirectorRegistry.get(msg.json.args[0]); + case "list": + return DirectorRegistry.list(); + default: + console.error(ERR_DIRECTOR_PARENT_UNKNOWN_METHOD, msg.json.method); + throw new Error(ERR_DIRECTOR_PARENT_UNKNOWN_METHOD); + } + } + + function setMessageManager(newMM) { + if (mm) { + mm.removeMessageListener("debug:director-registry-request", handleChildRequest); + } + mm = newMM; + if (mm) { + mm.addMessageListener("debug:director-registry-request", handleChildRequest); + } + } + + return { + onBrowserSwap: setMessageManager, + onDisconnected: () => setMessageManager(null), + }; +}; + +/** + * The DirectorRegistry Actor is a global actor which manages install/uninstall of + * director scripts definitions. + */ +const DirectorRegistryActor = exports.DirectorRegistryActor = protocol.ActorClassWithSpec(directorRegistrySpec, { + /* init & destroy methods */ + initialize: function (conn, parentActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.maybeSetupChildProcess(conn); + }, + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + this.finalize(); + }, + + finalize: function () { + // nothing to cleanup + }, + + maybeSetupChildProcess(conn) { + // skip child setup if this actor module is not running in a child process + if (!DebuggerServer.isInChildProcess) { + return; + } + + const { sendSyncMessage } = conn.parentMessageManager; + + conn.setupInParent({ + module: "devtools/server/actors/director-registry", + setupParent: "setupParentProcess" + }); + + DirectorRegistry.install = notImplemented.bind(null, "install"); + DirectorRegistry.uninstall = notImplemented.bind(null, "uninstall"); + DirectorRegistry.clear = notImplemented.bind(null, "clear"); + + DirectorRegistry.get = callParentProcess.bind(null, "get"); + DirectorRegistry.list = callParentProcess.bind(null, "list"); + + /* child process helpers */ + + function notImplemented(method) { + console.error(ERR_DIRECTOR_CHILD_NOTIMPLEMENTED_METHOD, method); + throw Error(ERR_DIRECTOR_CHILD_NOTIMPLEMENTED_METHOD); + } + + function callParentProcess(method, ...args) { + var reply = sendSyncMessage("debug:director-registry-request", { + method: method, + args: args + }); + + if (reply.length === 0) { + console.error(ERR_DIRECTOR_CHILD_NO_REPLY); + throw Error(ERR_DIRECTOR_CHILD_NO_REPLY); + } else if (reply.length > 1) { + console.error(ERR_DIRECTOR_CHILD_MULTIPLE_REPLIES); + throw Error(ERR_DIRECTOR_CHILD_MULTIPLE_REPLIES); + } + + return reply[0]; + } + }, + + /** + * Install a new director-script definition. + * + * @param String id + * The director-script definition identifier. + * @param String scriptCode + * The director-script javascript source. + * @param Object scriptOptions + * The director-script option object. + */ + install: function (id, { scriptCode, scriptOptions }) { + // TODO: add more checks on id format? + if (!id || id.length === 0) { + throw Error("director-script id is mandatory"); + } + + if (!scriptCode) { + throw Error("director-script scriptCode is mandatory"); + } + + return DirectorRegistry.install(id, { + scriptId: id, + scriptCode: scriptCode, + scriptOptions: scriptOptions + }); + }, + + /** + * Uninstall a director-script definition. + * + * @param String id + * The identifier of the director-script definition to be removed + */ + uninstall: function (id) { + return DirectorRegistry.uninstall(id); + }, + + /** + * Retrieves the list of installed director-scripts. + */ + list: function () { + return DirectorRegistry.list(); + } +}); diff --git a/devtools/server/actors/emulation.js b/devtools/server/actors/emulation.js new file mode 100644 index 000000000..b69183305 --- /dev/null +++ b/devtools/server/actors/emulation.js @@ -0,0 +1,241 @@ +/* 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 protocol = require("devtools/shared/protocol"); +const { emulationSpec } = require("devtools/shared/specs/emulation"); +const { SimulatorCore } = require("devtools/shared/touch/simulator-core"); + +/** + * This actor overrides various browser features to simulate different environments to + * test how pages perform under various conditions. + * + * The design below, which saves the previous value of each property before setting, is + * needed because it's possible to have multiple copies of this actor for a single page. + * When some instance of this actor changes a property, we want it to be able to restore + * that property to the way it was found before the change. + * + * A subtle aspect of the code below is that all get* methods must return non-undefined + * values, so that the absence of a previous value can be distinguished from the value for + * "no override" for each of the properties. + */ +let EmulationActor = protocol.ActorClassWithSpec(emulationSpec, { + + initialize(conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this.docShell = tabActor.docShell; + this.simulatorCore = new SimulatorCore(tabActor.chromeEventHandler); + }, + + disconnect() { + this.destroy(); + }, + + destroy() { + this.clearDPPXOverride(); + this.clearNetworkThrottling(); + this.clearTouchEventsOverride(); + this.clearUserAgentOverride(); + this.tabActor = null; + this.docShell = null; + this.simulatorCore = null; + protocol.Actor.prototype.destroy.call(this); + }, + + /** + * Retrieve the console actor for this tab. This allows us to expose network throttling + * as part of emulation settings, even though it's internally connected to the network + * monitor, which for historical reasons is part of the console actor. + */ + get _consoleActor() { + if (this.tabActor.exited) { + return null; + } + let form = this.tabActor.form(); + return this.conn._getOrCreateActor(form.consoleActor); + }, + + /* DPPX override */ + + _previousDPPXOverride: undefined, + + setDPPXOverride(dppx) { + if (this.getDPPXOverride() === dppx) { + return false; + } + + if (this._previousDPPXOverride === undefined) { + this._previousDPPXOverride = this.getDPPXOverride(); + } + + this.docShell.contentViewer.overrideDPPX = dppx; + + return true; + }, + + getDPPXOverride() { + return this.docShell.contentViewer.overrideDPPX; + }, + + clearDPPXOverride() { + if (this._previousDPPXOverride !== undefined) { + return this.setDPPXOverride(this._previousDPPXOverride); + } + + return false; + }, + + /* Network Throttling */ + + _previousNetworkThrottling: undefined, + + /** + * Transform the RDP format into the internal format and then set network throttling. + */ + setNetworkThrottling({ downloadThroughput, uploadThroughput, latency }) { + let throttleData = { + roundTripTimeMean: latency, + roundTripTimeMax: latency, + downloadBPSMean: downloadThroughput, + downloadBPSMax: downloadThroughput, + uploadBPSMean: uploadThroughput, + uploadBPSMax: uploadThroughput, + }; + return this._setNetworkThrottling(throttleData); + }, + + _setNetworkThrottling(throttleData) { + let current = this._getNetworkThrottling(); + // Check if they are both objects or both null + let match = throttleData == current; + // If both objects, check all entries + if (match && current && throttleData) { + match = Object.entries(current).every(([ k, v ]) => { + return throttleData[k] === v; + }); + } + if (match) { + return false; + } + + if (this._previousNetworkThrottling === undefined) { + this._previousNetworkThrottling = current; + } + + let consoleActor = this._consoleActor; + if (!consoleActor) { + return false; + } + consoleActor.onStartListeners({ + listeners: [ "NetworkActivity" ], + }); + consoleActor.onSetPreferences({ + preferences: { + "NetworkMonitor.throttleData": throttleData, + } + }); + return true; + }, + + /** + * Get network throttling and then transform the internal format into the RDP format. + */ + getNetworkThrottling() { + let throttleData = this._getNetworkThrottling(); + if (!throttleData) { + return null; + } + let { downloadBPSMax, uploadBPSMax, roundTripTimeMax } = throttleData; + return { + downloadThroughput: downloadBPSMax, + uploadThroughput: uploadBPSMax, + latency: roundTripTimeMax, + }; + }, + + _getNetworkThrottling() { + let consoleActor = this._consoleActor; + if (!consoleActor) { + return null; + } + let prefs = consoleActor.onGetPreferences({ + preferences: [ "NetworkMonitor.throttleData" ], + }); + return prefs.preferences["NetworkMonitor.throttleData"] || null; + }, + + clearNetworkThrottling() { + if (this._previousNetworkThrottling !== undefined) { + return this._setNetworkThrottling(this._previousNetworkThrottling); + } + + return false; + }, + + /* Touch events override */ + + _previousTouchEventsOverride: undefined, + + setTouchEventsOverride(flag) { + if (this.getTouchEventsOverride() == flag) { + return false; + } + if (this._previousTouchEventsOverride === undefined) { + this._previousTouchEventsOverride = this.getTouchEventsOverride(); + } + + // Start or stop the touch simulator depending on the override flag + if (flag == Ci.nsIDocShell.TOUCHEVENTS_OVERRIDE_ENABLED) { + this.simulatorCore.start(); + } else { + this.simulatorCore.stop(); + } + + this.docShell.touchEventsOverride = flag; + return true; + }, + + getTouchEventsOverride() { + return this.docShell.touchEventsOverride; + }, + + clearTouchEventsOverride() { + if (this._previousTouchEventsOverride !== undefined) { + return this.setTouchEventsOverride(this._previousTouchEventsOverride); + } + return false; + }, + + /* User agent override */ + + _previousUserAgentOverride: undefined, + + setUserAgentOverride(userAgent) { + if (this.getUserAgentOverride() == userAgent) { + return false; + } + if (this._previousUserAgentOverride === undefined) { + this._previousUserAgentOverride = this.getUserAgentOverride(); + } + this.docShell.customUserAgent = userAgent; + return true; + }, + + getUserAgentOverride() { + return this.docShell.customUserAgent; + }, + + clearUserAgentOverride() { + if (this._previousUserAgentOverride !== undefined) { + return this.setUserAgentOverride(this._previousUserAgentOverride); + } + return false; + }, + +}); + +exports.EmulationActor = EmulationActor; diff --git a/devtools/server/actors/environment.js b/devtools/server/actors/environment.js new file mode 100644 index 000000000..bd03586eb --- /dev/null +++ b/devtools/server/actors/environment.js @@ -0,0 +1,199 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; 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 { ActorClassWithSpec } = require("devtools/shared/protocol"); +const { createValueGrip } = require("devtools/server/actors/object"); +const { environmentSpec } = require("devtools/shared/specs/environment"); + +/** + * Creates an EnvironmentActor. EnvironmentActors are responsible for listing + * the bindings introduced by a lexical environment and assigning new values to + * those identifier bindings. + * + * @param Debugger.Environment aEnvironment + * The lexical environment that will be used to create the actor. + * @param ThreadActor aThreadActor + * The parent thread actor that contains this environment. + */ +let EnvironmentActor = ActorClassWithSpec(environmentSpec, { + initialize: function (environment, threadActor) { + this.obj = environment; + this.threadActor = threadActor; + }, + + /** + * Return an environment form for use in a protocol message. + */ + form: function () { + let form = { actor: this.actorID }; + + // What is this environment's type? + if (this.obj.type == "declarative") { + form.type = this.obj.callee ? "function" : "block"; + } else { + form.type = this.obj.type; + } + + // Does this environment have a parent? + if (this.obj.parent) { + form.parent = (this.threadActor + .createEnvironmentActor(this.obj.parent, + this.registeredPool) + .form()); + } + + // Does this environment reflect the properties of an object as variables? + if (this.obj.type == "object" || this.obj.type == "with") { + form.object = createValueGrip(this.obj.object, + this.registeredPool, this.threadActor.objectGrip); + } + + // Is this the environment created for a function call? + if (this.obj.callee) { + form.function = createValueGrip(this.obj.callee, + this.registeredPool, this.threadActor.objectGrip); + } + + // Shall we list this environment's bindings? + if (this.obj.type == "declarative") { + form.bindings = this.bindings(); + } + + return form; + }, + + /** + * Handle a protocol request to change the value of a variable bound in this + * lexical environment. + * + * @param string name + * The name of the variable to be changed. + * @param any value + * The value to be assigned. + */ + assign: function (name, value) { + // TODO: enable the commented-out part when getVariableDescriptor lands + // (bug 725815). + /* let desc = this.obj.getVariableDescriptor(name); + + if (!desc.writable) { + return { error: "immutableBinding", + message: "Changing the value of an immutable binding is not " + + "allowed" }; + }*/ + + try { + this.obj.setVariable(name, value); + } catch (e) { + if (e instanceof Debugger.DebuggeeWouldRun) { + throw { + error: "threadWouldRun", + message: "Assigning a value would cause the debuggee to run" + }; + } else { + throw e; + } + } + return { from: this.actorID }; + }, + + /** + * Handle a protocol request to fully enumerate the bindings introduced by the + * lexical environment. + */ + bindings: function () { + let bindings = { arguments: [], variables: {} }; + + // TODO: this part should be removed in favor of the commented-out part + // below when getVariableDescriptor lands (bug 725815). + if (typeof this.obj.getVariable != "function") { + // if (typeof this.obj.getVariableDescriptor != "function") { + return bindings; + } + + let parameterNames; + if (this.obj.callee) { + parameterNames = this.obj.callee.parameterNames; + } else { + parameterNames = []; + } + for (let name of parameterNames) { + let arg = {}; + let value = this.obj.getVariable(name); + + // TODO: this part should be removed in favor of the commented-out part + // below when getVariableDescriptor lands (bug 725815). + let desc = { + value: value, + configurable: false, + writable: !(value && value.optimizedOut), + enumerable: true + }; + + // let desc = this.obj.getVariableDescriptor(name); + let descForm = { + enumerable: true, + configurable: desc.configurable + }; + if ("value" in desc) { + descForm.value = createValueGrip(desc.value, + this.registeredPool, this.threadActor.objectGrip); + descForm.writable = desc.writable; + } else { + descForm.get = createValueGrip(desc.get, this.registeredPool, + this.threadActor.objectGrip); + descForm.set = createValueGrip(desc.set, this.registeredPool, + this.threadActor.objectGrip); + } + arg[name] = descForm; + bindings.arguments.push(arg); + } + + for (let name of this.obj.names()) { + if (bindings.arguments.some(function exists(element) { + return !!element[name]; + })) { + continue; + } + + let value = this.obj.getVariable(name); + + // TODO: this part should be removed in favor of the commented-out part + // below when getVariableDescriptor lands. + let desc = { + value: value, + configurable: false, + writable: !(value && + (value.optimizedOut || + value.uninitialized || + value.missingArguments)), + enumerable: true + }; + + // let desc = this.obj.getVariableDescriptor(name); + let descForm = { + enumerable: true, + configurable: desc.configurable + }; + if ("value" in desc) { + descForm.value = createValueGrip(desc.value, + this.registeredPool, this.threadActor.objectGrip); + descForm.writable = desc.writable; + } else { + descForm.get = createValueGrip(desc.get || undefined, + this.registeredPool, this.threadActor.objectGrip); + descForm.set = createValueGrip(desc.set || undefined, + this.registeredPool, this.threadActor.objectGrip); + } + bindings.variables[name] = descForm; + } + + return bindings; + } +}); + +exports.EnvironmentActor = EnvironmentActor; diff --git a/devtools/server/actors/errordocs.js b/devtools/server/actors/errordocs.js new file mode 100644 index 000000000..27f687dc7 --- /dev/null +++ b/devtools/server/actors/errordocs.js @@ -0,0 +1,84 @@ +/* this source code form is subject to the terms of the mozilla public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * A mapping of error message names to external documentation. Any error message + * included here will be displayed alongside its link in the web console. + */ + +"use strict"; + +const baseURL = "https://developer.mozilla.org/docs/Web/JavaScript/Reference/Errors/"; +const params = "?utm_source=mozilla&utm_medium=firefox-console-errors&utm_campaign=default"; +const ErrorDocs = { + JSMSG_READ_ONLY: "Read-only", + JSMSG_BAD_ARRAY_LENGTH: "Invalid_array_length", + JSMSG_NEGATIVE_REPETITION_COUNT: "Negative_repetition_count", + JSMSG_RESULTING_STRING_TOO_LARGE: "Resulting_string_too_large", + JSMSG_BAD_RADIX: "Bad_radix", + JSMSG_PRECISION_RANGE: "Precision_range", + JSMSG_BAD_FORMAL: "Malformed_formal_parameter", + JSMSG_STMT_AFTER_RETURN: "Stmt_after_return", + JSMSG_NOT_A_CODEPOINT: "Not_a_codepoint", + JSMSG_BAD_SORT_ARG: "Array_sort_argument", + JSMSG_UNEXPECTED_TYPE: "Unexpected_type", + JSMSG_NOT_DEFINED: "Not_defined", + JSMSG_NOT_FUNCTION: "Not_a_function", + JSMSG_EQUAL_AS_ASSIGN: "Equal_as_assign", + JSMSG_UNDEFINED_PROP: "Undefined_prop", + JSMSG_DEPRECATED_PRAGMA: "Deprecated_source_map_pragma", + JSMSG_DEPRECATED_USAGE: "Deprecated_caller_or_arguments_usage", + JSMSG_CANT_DELETE: "Cant_delete", + JSMSG_VAR_HIDES_ARG: "Var_hides_argument", + JSMSG_JSON_BAD_PARSE: "JSON_bad_parse", + JSMSG_UNDECLARED_VAR: "Undeclared_var", + JSMSG_UNEXPECTED_TOKEN: "Unexpected_token", + JSMSG_BAD_OCTAL: "Bad_octal", + JSMSG_PROPERTY_ACCESS_DENIED: "Property_access_denied", + JSMSG_NO_PROPERTIES: "No_properties", + JSMSG_ALREADY_HAS_PRAGMA: "Already_has_pragma", + JSMSG_BAD_RETURN_OR_YIELD: "Bad_return_or_yield", + JSMSG_SEMI_BEFORE_STMNT: "Missing_semicolon_before_statement", + JSMSG_OVER_RECURSED: "Too_much_recursion", + JSMSG_BRACKET_AFTER_LIST: "Missing_bracket_after_list", + JSMSG_PAREN_AFTER_ARGS: "Missing_parenthesis_after_argument_list", + JSMSG_MORE_ARGS_NEEDED: "More_arguments_needed", + JSMSG_BAD_LEFTSIDE_OF_ASS: "Invalid_assignment_left-hand_side", + JSMSG_UNTERMINATED_STRING: "Unterminated_string_literal", + JSMSG_NOT_CONSTRUCTOR: "Not_a_constructor", + JSMSG_CURLY_AFTER_LIST: "Missing_curly_after_property_list", + JSMSG_DEPRECATED_FOR_EACH: "For-each-in_loops_are_deprecated", +}; + +const MIXED_CONTENT_LEARN_MORE = "https://developer.mozilla.org/docs/Web/Security/Mixed_content"; +const TRACKING_PROTECTION_LEARN_MORE = "https://developer.mozilla.org/Firefox/Privacy/Tracking_Protection"; +const INSECURE_PASSWORDS_LEARN_MORE = "https://developer.mozilla.org/docs/Web/Security/Insecure_passwords"; +const PUBLIC_KEY_PINS_LEARN_MORE = "https://developer.mozilla.org/docs/Web/Security/Public_Key_Pinning"; +const STRICT_TRANSPORT_SECURITY_LEARN_MORE = "https://developer.mozilla.org/docs/Web/Security/HTTP_strict_transport_security"; +const WEAK_SIGNATURE_ALGORITHM_LEARN_MORE = "https://developer.mozilla.org/docs/Web/Security/Weak_Signature_Algorithm"; +const ErrorCategories = { + "Insecure Password Field": INSECURE_PASSWORDS_LEARN_MORE, + "Mixed Content Message": MIXED_CONTENT_LEARN_MORE, + "Mixed Content Blocker": MIXED_CONTENT_LEARN_MORE, + "Invalid HPKP Headers": PUBLIC_KEY_PINS_LEARN_MORE, + "Invalid HSTS Headers": STRICT_TRANSPORT_SECURITY_LEARN_MORE, + "SHA-1 Signature": WEAK_SIGNATURE_ALGORITHM_LEARN_MORE, + "Tracking Protection": TRACKING_PROTECTION_LEARN_MORE, +}; + +exports.GetURL = (error) => { + if (!error) { + return; + } + + let doc = ErrorDocs[error.errorMessageName]; + if (doc) { + return baseURL + doc + params; + } + + let categoryURL = ErrorCategories[error.category]; + if (categoryURL) { + return categoryURL + params; + } +}; diff --git a/devtools/server/actors/eventlooplag.js b/devtools/server/actors/eventlooplag.js new file mode 100644 index 000000000..6e0a101c9 --- /dev/null +++ b/devtools/server/actors/eventlooplag.js @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * The eventLoopLag actor emits "event-loop-lag" events when the event + * loop gets unresponsive. The event comes with a "time" property (the + * duration of the lag in milliseconds). + */ + +const {Ci} = require("chrome"); +const Services = require("Services"); +const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); +const {Actor, ActorClassWithSpec} = require("devtools/shared/protocol"); +const events = require("sdk/event/core"); +const {eventLoopLagSpec} = require("devtools/shared/specs/eventlooplag"); + +var EventLoopLagActor = exports.EventLoopLagActor = ActorClassWithSpec(eventLoopLagSpec, { + _observerAdded: false, + + /** + * Start tracking the event loop lags. + */ + start: function () { + if (!this._observerAdded) { + Services.obs.addObserver(this, "event-loop-lag", false); + this._observerAdded = true; + } + return Services.appShell.startEventLoopLagTracking(); + }, + + /** + * Stop tracking the event loop lags. + */ + stop: function () { + if (this._observerAdded) { + Services.obs.removeObserver(this, "event-loop-lag"); + this._observerAdded = false; + } + Services.appShell.stopEventLoopLagTracking(); + }, + + destroy: function () { + this.stop(); + Actor.prototype.destroy.call(this); + }, + + // nsIObserver + + observe: function (subject, topic, data) { + if (topic == "event-loop-lag") { + // Forward event loop lag event + events.emit(this, "event-loop-lag", data); + } + }, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), +}); diff --git a/devtools/server/actors/frame.js b/devtools/server/actors/frame.js new file mode 100644 index 000000000..fefcad1b0 --- /dev/null +++ b/devtools/server/actors/frame.js @@ -0,0 +1,100 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; 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 { ActorPool } = require("devtools/server/actors/common"); +const { createValueGrip } = require("devtools/server/actors/object"); +const { ActorClassWithSpec } = require("devtools/shared/protocol"); +const { frameSpec } = require("devtools/shared/specs/frame"); + +/** + * An actor for a specified stack frame. + */ +let FrameActor = ActorClassWithSpec(frameSpec, { + /** + * Creates the Frame actor. + * + * @param frame Debugger.Frame + * The debuggee frame. + * @param threadActor ThreadActor + * The parent thread actor for this frame. + */ + initialize: function (frame, threadActor) { + this.frame = frame; + this.threadActor = threadActor; + }, + + /** + * A pool that contains frame-lifetime objects, like the environment. + */ + _frameLifetimePool: null, + get frameLifetimePool() { + if (!this._frameLifetimePool) { + this._frameLifetimePool = new ActorPool(this.conn); + this.conn.addActorPool(this._frameLifetimePool); + } + return this._frameLifetimePool; + }, + + /** + * Finalization handler that is called when the actor is being evicted from + * the pool. + */ + disconnect: function () { + this.conn.removeActorPool(this._frameLifetimePool); + this._frameLifetimePool = null; + }, + + /** + * Returns a frame form for use in a protocol message. + */ + form: function () { + let threadActor = this.threadActor; + let form = { actor: this.actorID, + type: this.frame.type }; + if (this.frame.type === "call") { + form.callee = createValueGrip(this.frame.callee, threadActor._pausePool, + threadActor.objectGrip); + } + + if (this.frame.environment) { + let envActor = threadActor.createEnvironmentActor( + this.frame.environment, + this.frameLifetimePool + ); + form.environment = envActor.form(); + } + form.this = createValueGrip(this.frame.this, threadActor._pausePool, + threadActor.objectGrip); + form.arguments = this._args(); + if (this.frame.script) { + var generatedLocation = this.threadActor.sources.getFrameLocation(this.frame); + form.where = { + source: generatedLocation.generatedSourceActor.form(), + line: generatedLocation.generatedLine, + column: generatedLocation.generatedColumn + }; + } + + if (!this.frame.older) { + form.oldest = true; + } + + return form; + }, + + _args: function () { + if (!this.frame.arguments) { + return []; + } + + return this.frame.arguments.map(arg => createValueGrip(arg, + this.threadActor._pausePool, this.threadActor.objectGrip)); + } +}); + +exports.FrameActor = FrameActor; diff --git a/devtools/server/actors/framerate.js b/devtools/server/actors/framerate.js new file mode 100644 index 000000000..872cd7360 --- /dev/null +++ b/devtools/server/actors/framerate.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { Actor, ActorClassWithSpec } = require("devtools/shared/protocol"); +const { actorBridgeWithSpec } = require("devtools/server/actors/common"); +const { on, once, off, emit } = require("sdk/event/core"); +const { Framerate } = require("devtools/server/performance/framerate"); +const { framerateSpec } = require("devtools/shared/specs/framerate"); + +/** + * An actor wrapper around Framerate. Uses exposed + * methods via bridge and provides RDP definitions. + * + * @see devtools/server/performance/framerate.js for documentation. + */ +var FramerateActor = exports.FramerateActor = ActorClassWithSpec(framerateSpec, { + initialize: function (conn, tabActor) { + Actor.prototype.initialize.call(this, conn); + this.bridge = new Framerate(tabActor); + }, + destroy: function (conn) { + Actor.prototype.destroy.call(this, conn); + this.bridge.destroy(); + }, + + startRecording: actorBridgeWithSpec("startRecording"), + stopRecording: actorBridgeWithSpec("stopRecording"), + cancelRecording: actorBridgeWithSpec("cancelRecording"), + isRecording: actorBridgeWithSpec("isRecording"), + getPendingTicks: actorBridgeWithSpec("getPendingTicks"), +}); diff --git a/devtools/server/actors/gcli.js b/devtools/server/actors/gcli.js new file mode 100644 index 000000000..651825a28 --- /dev/null +++ b/devtools/server/actors/gcli.js @@ -0,0 +1,233 @@ +/* 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 { + method, Arg, Option, RetVal, Actor, ActorClassWithSpec +} = require("devtools/shared/protocol"); +const { gcliSpec } = require("devtools/shared/specs/gcli"); +const events = require("sdk/event/core"); +const { createSystem } = require("gcli/system"); + +/** + * Manage remote connections that want to talk to GCLI + */ +const GcliActor = ActorClassWithSpec(gcliSpec, { + initialize: function (conn, tabActor) { + Actor.prototype.initialize.call(this, conn); + + this._commandsChanged = this._commandsChanged.bind(this); + + this._tabActor = tabActor; + this._requisitionPromise = undefined; // see _getRequisition() + }, + + disconnect: function () { + return this.destroy(); + }, + + destroy: function () { + Actor.prototype.destroy.call(this); + + // If _getRequisition has not been called, just bail quickly + if (this._requisitionPromise == null) { + this._commandsChanged = undefined; + this._tabActor = undefined; + return Promise.resolve(); + } + + return this._getRequisition().then(requisition => { + requisition.destroy(); + + this._system.commands.onCommandsChange.remove(this._commandsChanged); + this._system.destroy(); + this._system = undefined; + + this._requisitionPromise = undefined; + this._tabActor = undefined; + + this._commandsChanged = undefined; + }); + }, + + /** + * Load a module into the requisition + */ + _testOnlyAddItemsByModule: function (names) { + return this._getRequisition().then(requisition => { + return requisition.system.addItemsByModule(names); + }); + }, + + /** + * Unload a module from the requisition + */ + _testOnlyRemoveItemsByModule: function (names) { + return this._getRequisition().then(requisition => { + return requisition.system.removeItemsByModule(names); + }); + }, + + /** + * Retrieve a list of the remotely executable commands + * @param customProps Array of strings containing additional properties which, + * if specified in the command spec, will be included in the JSON. Normally we + * transfer only the properties required for GCLI to function. + */ + specs: function (customProps) { + return this._getRequisition().then(requisition => { + return requisition.system.commands.getCommandSpecs(customProps); + }); + }, + + /** + * Execute a GCLI command + * @return a promise of an object with the following properties: + * - data: The output of the command + * - type: The type of the data to allow selection of a converter + * - error: True if the output was considered an error + */ + execute: function (typed) { + return this._getRequisition().then(requisition => { + return requisition.updateExec(typed).then(output => output.toJson()); + }); + }, + + /** + * Get the state of an input string. i.e. requisition.getStateData() + */ + state: function (typed, start, rank) { + return this._getRequisition().then(requisition => { + return requisition.update(typed).then(() => { + return requisition.getStateData(start, rank); + }); + }); + }, + + /** + * Call type.parse to check validity. Used by the remote type + * @return a promise of an object with the following properties: + * - status: Of of the following strings: VALID|INCOMPLETE|ERROR + * - message: The message to display to the user + * - predictions: An array of suggested values for the given parameter + */ + parseType: function (typed, paramName) { + return this._getRequisition().then(requisition => { + return requisition.update(typed).then(() => { + let assignment = requisition.getAssignment(paramName); + return Promise.resolve(assignment.predictions).then(predictions => { + return { + status: assignment.getStatus().toString(), + message: assignment.message, + predictions: predictions + }; + }); + }); + }); + }, + + /** + * Get the incremented/decremented value of some type + * @return a promise of a string containing the new argument text + */ + nudgeType: function (typed, by, paramName) { + return this.requisition.update(typed).then(() => { + const assignment = this.requisition.getAssignment(paramName); + return this.requisition.nudge(assignment, by).then(() => { + return assignment.arg == null ? undefined : assignment.arg.text; + }); + }); + }, + + /** + * Perform a lookup on a selection type to get the allowed values + */ + getSelectionLookup: function (commandName, paramName) { + return this._getRequisition().then(requisition => { + const command = requisition.system.commands.get(commandName); + if (command == null) { + throw new Error("No command called '" + commandName + "'"); + } + + let type; + command.params.forEach(param => { + if (param.name === paramName) { + type = param.type; + } + }); + + if (type == null) { + throw new Error("No parameter called '" + paramName + "' in '" + + commandName + "'"); + } + + const reply = type.getLookup(requisition.executionContext); + return Promise.resolve(reply).then(lookup => { + // lookup returns an array of objects with name/value properties and + // the values might not be JSONable, so remove them + return lookup.map(info => ({ name: info.name })); + }); + }); + }, + + /** + * Lazy init for a Requisition + */ + _getRequisition: function () { + if (this._tabActor == null) { + throw new Error("GcliActor used post-destroy"); + } + + if (this._requisitionPromise != null) { + return this._requisitionPromise; + } + + const Requisition = require("gcli/cli").Requisition; + const tabActor = this._tabActor; + + this._system = createSystem({ location: "server" }); + this._system.commands.onCommandsChange.add(this._commandsChanged); + + const gcliInit = require("devtools/shared/gcli/commands/index"); + gcliInit.addAllItemsByModule(this._system); + + // this._requisitionPromise should be created synchronously with the call + // to _getRequisition so that destroy can tell whether there is an async + // init in progress + this._requisitionPromise = this._system.load().then(() => { + const environment = { + get chromeWindow() { + throw new Error("environment.chromeWindow is not available in runAt:server commands"); + }, + + get chromeDocument() { + throw new Error("environment.chromeDocument is not available in runAt:server commands"); + }, + + get window() { + return tabActor.window; + }, + + get document() { + return tabActor.window && tabActor.window.document; + } + }; + + return new Requisition(this._system, { environment: environment }); + }); + + return this._requisitionPromise; + }, + + /** + * Pass events from requisition.system.commands.onCommandsChange upwards + */ + _commandsChanged: function () { + events.emit(this, "commands-changed"); + }, +}); + +exports.GcliActor = GcliActor; diff --git a/devtools/server/actors/heap-snapshot-file.js b/devtools/server/actors/heap-snapshot-file.js new file mode 100644 index 000000000..545f2265d --- /dev/null +++ b/devtools/server/actors/heap-snapshot-file.js @@ -0,0 +1,68 @@ +/* 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 protocol = require("devtools/shared/protocol"); +const { method, Arg } = protocol; +const Services = require("Services"); +const { Task } = require("devtools/shared/task"); + +const { heapSnapshotFileSpec } = require("devtools/shared/specs/heap-snapshot-file"); + +loader.lazyRequireGetter(this, "DevToolsUtils", + "devtools/shared/DevToolsUtils"); +loader.lazyRequireGetter(this, "OS", "resource://gre/modules/osfile.jsm", true); +loader.lazyRequireGetter(this, "HeapSnapshotFileUtils", + "devtools/shared/heapsnapshot/HeapSnapshotFileUtils"); + +/** + * The HeapSnapshotFileActor handles transferring heap snapshot files from the + * server to the client. This has to be a global actor in the parent process + * because child processes are sandboxed and do not have access to the file + * system. + */ +exports.HeapSnapshotFileActor = protocol.ActorClassWithSpec(heapSnapshotFileSpec, { + initialize: function (conn, parent) { + if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) { + const err = new Error("Attempt to create a HeapSnapshotFileActor in a " + + "child process! The HeapSnapshotFileActor *MUST* " + + "be in the parent process!"); + DevToolsUtils.reportException( + "HeapSnapshotFileActor.prototype.initialize", err); + return; + } + + protocol.Actor.prototype.initialize.call(this, conn, parent); + }, + + /** + * @see MemoryFront.prototype.transferHeapSnapshot + */ + transferHeapSnapshot: Task.async(function* (snapshotId) { + const snapshotFilePath = + HeapSnapshotFileUtils.getHeapSnapshotTempFilePath(snapshotId); + if (!snapshotFilePath) { + throw new Error(`No heap snapshot with id: ${snapshotId}`); + } + + const streamPromise = DevToolsUtils.openFileStream(snapshotFilePath); + + const { size } = yield OS.File.stat(snapshotFilePath); + const bulkPromise = this.conn.startBulkSend({ + actor: this.actorID, + type: "heap-snapshot", + length: size + }); + + const [bulk, stream] = yield Promise.all([bulkPromise, streamPromise]); + + try { + yield bulk.copyFrom(stream); + } finally { + stream.close(); + } + }), + +}); diff --git a/devtools/server/actors/highlighters.css b/devtools/server/actors/highlighters.css new file mode 100644 index 000000000..87375ea36 --- /dev/null +++ b/devtools/server/actors/highlighters.css @@ -0,0 +1,536 @@ +/* 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/. */ + +/* + The :-moz-native-anonymous selector prefix prevents the styles defined here + from impacting web content. Indeed, this pseudo-class is only available to chrome code. + This stylesheet is loaded as a ua stylesheet via the addon sdk, so having this + pseudo-class is important. + Having bug 1086532 fixed would make it possible to load this stylesheet in a + <style scoped> node instead, directly in the native anonymous container + element. + + A specific selector should still be specified to avoid impacting non-devtools + chrome content. +*/ + +:-moz-native-anonymous .highlighter-container { + /* + Content CSS applying to the html element impact the highlighters. + To avoid that, possible cases have been set to initial. + */ + text-transform: initial; + text-indent: initial; + letter-spacing: initial; + word-spacing: initial; + color: initial; +} + +:-moz-native-anonymous .highlighter-container { + --highlighter-guide-color: #08c; + --highlighter-content-color: #87ceeb; + --highlighter-bubble-text-color: hsl(216, 33%, 97%); + --highlighter-bubble-background-color: hsl(214, 13%, 24%); + --highlighter-bubble-border-color: rgba(255, 255, 255, 0.2); +} + +:-moz-native-anonymous .highlighter-container { + position: fixed; + width: 100%; + height: 100%; + /* The container for all highlighters doesn't react to pointer-events by + default. This is because most highlighters cover the whole viewport but + don't contain UIs that need to be accessed. + If your highlighter has UI that needs to be interacted with, add + 'pointer-events:auto;' on its container element. */ + pointer-events: none; +} + +:-moz-native-anonymous .highlighter-container.box-model { + /* Make the box-model container have a z-index other than auto so it always sits above + other highlighters. */ + z-index: 1; +} + +:-moz-native-anonymous .highlighter-container [hidden] { + display: none; +} + +:-moz-native-anonymous .highlighter-container [dragging] { + cursor: grabbing; +} + +/* Box Model Highlighter */ + +:-moz-native-anonymous .box-model-regions { + opacity: 0.6; +} + +/* Box model regions can be faded (see the onlyRegionArea option in + highlighters.js) in order to only display certain regions. */ +:-moz-native-anonymous .box-model-regions [faded] { + display: none; +} + +:-moz-native-anonymous .box-model-content { + fill: var(--highlighter-content-color); +} + +:-moz-native-anonymous .box-model-padding { + fill: #6a5acd; +} + +:-moz-native-anonymous .box-model-border { + fill: #444444; +} + +:-moz-native-anonymous .box-model-margin { + fill: #edff64; +} + +:-moz-native-anonymous .box-model-content, +:-moz-native-anonymous .box-model-padding, +:-moz-native-anonymous .box-model-border, +:-moz-native-anonymous .box-model-margin { + stroke: none; +} + +:-moz-native-anonymous .box-model-guide-top, +:-moz-native-anonymous .box-model-guide-right, +:-moz-native-anonymous .box-model-guide-bottom, +:-moz-native-anonymous .box-model-guide-left { + stroke: var(--highlighter-guide-color); + stroke-dasharray: 5 3; + shape-rendering: crispEdges; +} + +/* Highlighter - Infobar */ + +:-moz-native-anonymous [class$=infobar-container] { + position: absolute; + max-width: 95%; + + font: message-box; + font-size: 11px; +} + +:-moz-native-anonymous [class$=infobar] { + position: relative; + + /* Centering the infobar in the container */ + left: -50%; + + padding: 5px; + min-width: 75px; + + border-radius: 3px; + background: var(--highlighter-bubble-background-color) no-repeat padding-box; + + color: var(--highlighter-bubble-text-color); + text-shadow: none; + + border: 1px solid var(--highlighter-bubble-border-color); +} + +:-moz-native-anonymous [class$=infobar-container][hide-arrow] > [class$=infobar] { + margin: 7px 0; +} + +/* Arrows */ + +:-moz-native-anonymous [class$=infobar-container] > [class$=infobar]:before { + left: calc(50% - 8px); + border: 8px solid var(--highlighter-bubble-border-color); +} + +:-moz-native-anonymous [class$=infobar-container] > [class$=infobar]:after { + left: calc(50% - 7px); + border: 7px solid var(--highlighter-bubble-background-color); +} + +:-moz-native-anonymous [class$=infobar-container] > [class$=infobar]:before, +:-moz-native-anonymous [class$=infobar-container] > [class$=infobar]:after { + content: ""; + display: none; + position: absolute; + height: 0; + width: 0; + border-left-color: transparent; + border-right-color: transparent; +} + +:-moz-native-anonymous [class$=infobar-container][position="top"]:not([hide-arrow]) > [class$=infobar]:before, +:-moz-native-anonymous [class$=infobar-container][position="top"]:not([hide-arrow]) > [class$=infobar]:after { + border-bottom: 0; + top: 100%; + display: block; +} + +:-moz-native-anonymous [class$=infobar-container][position="bottom"]:not([hide-arrow]) > [class$=infobar]:before, +:-moz-native-anonymous [class$=infobar-container][position="bottom"]:not([hide-arrow]) > [class$=infobar]:after { + border-top: 0; + bottom: 100%; + display: block; +} + +/* Text Container */ + +:-moz-native-anonymous [class$=infobar-text] { + overflow: hidden; + white-space: nowrap; + direction: ltr; + padding-bottom: 1px; + display: flex; +} + +:-moz-native-anonymous .box-model-infobar-tagname { + color: hsl(285,100%, 75%); +} + +:-moz-native-anonymous .box-model-infobar-id { + color: hsl(103, 46%, 54%); + overflow: hidden; + text-overflow: ellipsis; +} + +:-moz-native-anonymous .box-model-infobar-classes, +:-moz-native-anonymous .box-model-infobar-pseudo-classes { + color: hsl(200, 74%, 57%); + overflow: hidden; + text-overflow: ellipsis; +} + +:-moz-native-anonymous [class$=infobar-dimensions] { + color: hsl(210, 30%, 85%); + border-inline-start: 1px solid #5a6169; + margin-inline-start: 6px; + padding-inline-start: 6px; +} + +/* CSS Grid Highlighter */ + +:-moz-native-anonymous .css-grid-canvas { + position: absolute; + pointer-events: none; + top: 0; + left: 0; + image-rendering: -moz-crisp-edges; +} + +:-moz-native-anonymous .css-grid-regions { + opacity: 0.6; +} + +:-moz-native-anonymous .css-grid-areas { + fill: #CEC0ED; + stroke: none; +} + +:-moz-native-anonymous .css-grid-infobar-areaname { + color: hsl(285,100%, 75%); +} + +/* CSS Transform Highlighter */ + +:-moz-native-anonymous .css-transform-transformed { + fill: var(--highlighter-content-color); + opacity: 0.8; +} + +:-moz-native-anonymous .css-transform-untransformed { + fill: #66cc52; + opacity: 0.8; +} + +:-moz-native-anonymous .css-transform-transformed, +:-moz-native-anonymous .css-transform-untransformed, +:-moz-native-anonymous .css-transform-line { + stroke: var(--highlighter-guide-color); + stroke-dasharray: 5 3; + stroke-width: 2; +} + +/* Rect Highlighter */ + +:-moz-native-anonymous .highlighted-rect { + position: absolute; + background: var(--highlighter-content-color); + opacity: 0.8; +} + +/* Element Geometry Highlighter */ + +:-moz-native-anonymous .geometry-editor-root { + /* The geometry editor can be interacted with, so it needs to react to + pointer events */ + pointer-events: auto; + -moz-user-select: none; +} + +:-moz-native-anonymous .geometry-editor-offset-parent { + stroke: var(--highlighter-guide-color); + shape-rendering: crispEdges; + stroke-dasharray: 5 3; + fill: transparent; +} + +:-moz-native-anonymous .geometry-editor-current-node { + stroke: var(--highlighter-guide-color); + fill: var(--highlighter-content-color); + shape-rendering: crispEdges; + opacity: 0.6; +} + +:-moz-native-anonymous .geometry-editor-arrow { + stroke: var(--highlighter-guide-color); + shape-rendering: crispEdges; +} + +:-moz-native-anonymous .geometry-editor-root circle { + stroke: var(--highlighter-guide-color); + fill: var(--highlighter-content-color); +} + +:-moz-native-anonymous .geometry-editor-handler-top, +:-moz-native-anonymous .geometry-editor-handler-bottom { + cursor: ns-resize; +} + +:-moz-native-anonymous .geometry-editor-handler-right, +:-moz-native-anonymous .geometry-editor-handler-left { + cursor: ew-resize; +} + +:-moz-native-anonymous [dragging] .geometry-editor-handler-top, +:-moz-native-anonymous [dragging] .geometry-editor-handler-right, +:-moz-native-anonymous [dragging] .geometry-editor-handler-bottom, +:-moz-native-anonymous [dragging] .geometry-editor-handler-left { + cursor: grabbing; +} + +:-moz-native-anonymous .geometry-editor-handler-top.dragging, +:-moz-native-anonymous .geometry-editor-handler-right.dragging, +:-moz-native-anonymous .geometry-editor-handler-bottom.dragging, +:-moz-native-anonymous .geometry-editor-handler-left.dragging { + fill: var(--highlighter-guide-color); +} + +:-moz-native-anonymous .geometry-editor-label-bubble { + fill: var(--highlighter-bubble-background-color); + shape-rendering: crispEdges; +} + +:-moz-native-anonymous .geometry-editor-label-text { + fill: var(--highlighter-bubble-text-color); + font: message-box; + font-size: 10px; + text-anchor: middle; + dominant-baseline: middle; +} + +/* Rulers Highlighter */ + +:-moz-native-anonymous .rulers-highlighter-elements { + shape-rendering: crispEdges; + pointer-events: none; + position: fixed; + top: 0; + left: 0; +} + +:-moz-native-anonymous .rulers-highlighter-elements > g { + opacity: 0.8; +} + +:-moz-native-anonymous .rulers-highlighter-elements > g > rect { + fill: #fff; +} + +:-moz-native-anonymous .rulers-highlighter-ruler-graduations { + stroke: #bebebe; +} + +:-moz-native-anonymous .rulers-highlighter-ruler-markers { + stroke: #202020; +} + +:-moz-native-anonymous .rulers-highlighter-horizontal-labels > text, +:-moz-native-anonymous .rulers-highlighter-vertical-labels > text { + stroke: none; + fill: #202020; + font: message-box; + font-size: 9px; + dominant-baseline: hanging; +} + +:-moz-native-anonymous .rulers-highlighter-horizontal-labels > text { + text-anchor: start; +} + +:-moz-native-anonymous .rulers-highlighter-vertical-labels > text { + transform: rotate(-90deg); + text-anchor: end; +} + +/* Measuring Tool Highlighter */ + +:-moz-native-anonymous .measuring-tool-highlighter-root { + position: absolute; + top: 0; + left: 0; + pointer-events: auto; + cursor: crosshair; +} + +:-moz-native-anonymous .measuring-tool-highlighter-root path { + shape-rendering: crispEdges; + fill: rgba(135, 206, 235, 0.6); + stroke: var(--highlighter-guide-color); + pointer-events: none; +} + +:-moz-native-anonymous .dragging path { + fill: rgba(135, 206, 235, 0.6); + stroke: var(--highlighter-guide-color); + opacity: 0.45; +} + +:-moz-native-anonymous .measuring-tool-highlighter-label-size, +:-moz-native-anonymous .measuring-tool-highlighter-label-position { + position: absolute; + top: 0; + left: 0; + display: inline-block; + border-radius: 4px; + padding: 4px; + white-space: pre-line; + font: message-box; + font-size: 10px; + pointer-events: none; + -moz-user-select: none; + box-sizing: border-box; +} + +:-moz-native-anonymous .measuring-tool-highlighter-label-position { + color: #fff; + background: hsla(214, 13%, 24%, 0.8); +} + +:-moz-native-anonymous .measuring-tool-highlighter-label-size { + color: var(--highlighter-bubble-text-color); + background: var(--highlighter-bubble-background-color); + border: 1px solid var(--highlighter-bubble-border-color); + line-height: 1.5em; +} + +:-moz-native-anonymous .measuring-tool-highlighter-guide-top, +:-moz-native-anonymous .measuring-tool-highlighter-guide-right, +:-moz-native-anonymous .measuring-tool-highlighter-guide-bottom, +:-moz-native-anonymous .measuring-tool-highlighter-guide-left { + stroke: var(--highlighter-guide-color); + stroke-dasharray: 5 3; + shape-rendering: crispEdges; +} + +/* Eye Dropper */ + +:-moz-native-anonymous .eye-dropper-root { + --magnifier-width: 96px; + --magnifier-height: 96px; + /* Width accounts for all color formats (hsl being the longest) */ + --label-width: 160px; + --label-height: 23px; + --color: #e0e0e0; + + position: absolute; + /* Tool start position. This should match the X/Y defines in JS */ + top: 100px; + left: 100px; + + /* Prevent interacting with the page when hovering and clicking */ + pointer-events: auto; + + /* Offset the UI so it is centered around the pointer */ + transform: translate( + calc(var(--magnifier-width) / -2), calc(var(--magnifier-height) / -2)); + + filter: drop-shadow(0 0 1px rgba(0,0,0,.4)); + + /* We don't need the UI to be reversed in RTL locales, otherwise the # would appear + to the right of the hex code. Force LTR */ + direction: ltr; +} + +:-moz-native-anonymous .eye-dropper-canvas { + image-rendering: -moz-crisp-edges; + cursor: none; + width: var(--magnifier-width); + height: var(--magnifier-height); + border-radius: 50%; + box-shadow: 0 0 0 3px var(--color); + display: block; +} + +:-moz-native-anonymous .eye-dropper-color-container { + background-color: var(--color); + border-radius: 2px; + width: var(--label-width); + height: var(--label-height); + position: relative; + + --label-horizontal-center: + translateX(calc((var(--magnifier-width) - var(--label-width)) / 2)); + --label-horizontal-left: + translateX(calc((-1 * var(--label-width) + var(--magnifier-width) / 2))); + --label-horizontal-right: + translateX(calc(var(--magnifier-width) / 2)); + --label-vertical-top: + translateY(calc((-1 * var(--magnifier-height)) - var(--label-height))); + + /* By default the color label container sits below the canvas. + Here we just center it horizontally */ + transform: var(--label-horizontal-center); + transition: transform .1s ease-in-out; +} + +/* If there isn't enough space below the canvas, we move the label container to the top */ +:-moz-native-anonymous .eye-dropper-root[top] .eye-dropper-color-container { + transform: var(--label-horizontal-center) var(--label-vertical-top); +} + +/* If there isn't enough space right of the canvas to horizontally center the label + container, offset it to the left */ +:-moz-native-anonymous .eye-dropper-root[left] .eye-dropper-color-container { + transform: var(--label-horizontal-left); +} +:-moz-native-anonymous .eye-dropper-root[left][top] .eye-dropper-color-container { + transform: var(--label-horizontal-left) var(--label-vertical-top); +} + +/* If there isn't enough space left of the canvas to horizontally center the label + container, offset it to the right */ +:-moz-native-anonymous .eye-dropper-root[right] .eye-dropper-color-container { + transform: var(--label-horizontal-right); +} +:-moz-native-anonymous .eye-dropper-root[right][top] .eye-dropper-color-container { + transform: var(--label-horizontal-right) var(--label-vertical-top); +} + +:-moz-native-anonymous .eye-dropper-color-preview { + width: 16px; + height: 16px; + position: absolute; + offset-inline-start: 3px; + offset-block-start: 3px; + box-shadow: 0px 0px 0px black; + border: solid 1px #fff; +} + +:-moz-native-anonymous .eye-dropper-color-value { + text-shadow: 1px 1px 1px #fff; + font: message-box; + font-size: 11px; + text-align: center; + padding: 4px 0; +} diff --git a/devtools/server/actors/highlighters.js b/devtools/server/actors/highlighters.js new file mode 100644 index 000000000..087750ab3 --- /dev/null +++ b/devtools/server/actors/highlighters.js @@ -0,0 +1,715 @@ +/* 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 { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); +const EventEmitter = require("devtools/shared/event-emitter"); +const events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const Services = require("Services"); +const { isWindowIncluded } = require("devtools/shared/layout/utils"); +const { highlighterSpec, customHighlighterSpec } = require("devtools/shared/specs/highlighters"); +const { isXUL } = require("./highlighters/utils/markup"); +const { SimpleOutlineHighlighter } = require("./highlighters/simple-outline"); + +const HIGHLIGHTER_PICKED_TIMER = 1000; +const IS_OSX = Services.appinfo.OS === "Darwin"; + +/** + * The registration mechanism for highlighters provide a quick way to + * have modular highlighters, instead of a hard coded list. + * It allow us to split highlighers in sub modules, and add them dynamically + * using add-on (useful for 3rd party developers, or prototyping) + * + * Note that currently, highlighters added using add-ons, can only work on + * Firefox desktop, or Fennec if the same add-on is installed in both. + */ +const highlighterTypes = new Map(); + +/** + * Returns `true` if a highlighter for the given `typeName` is registered, + * `false` otherwise. + */ +const isTypeRegistered = (typeName) => highlighterTypes.has(typeName); +exports.isTypeRegistered = isTypeRegistered; + +/** + * Registers a given constructor as highlighter, for the `typeName` given. + * If no `typeName` is provided, is looking for a `typeName` property in + * the prototype's constructor. + */ +const register = (constructor, typeName = constructor.prototype.typeName) => { + if (!typeName) { + throw Error("No type's name found, or provided."); + } + + if (highlighterTypes.has(typeName)) { + throw Error(`${typeName} is already registered.`); + } + + highlighterTypes.set(typeName, constructor); +}; +exports.register = register; + +/** + * The Highlighter is the server-side entry points for any tool that wishes to + * highlight elements in some way in the content document. + * + * A little bit of vocabulary: + * - <something>HighlighterActor classes are the actors that can be used from + * the client. They do very little else than instantiate a given + * <something>Highlighter and use it to highlight elements. + * - <something>Highlighter classes aren't actors, they're just JS classes that + * know how to create and attach the actual highlighter elements on top of the + * content + * + * The most used highlighter actor is the HighlighterActor which can be + * conveniently retrieved via the InspectorActor's 'getHighlighter' method. + * The InspectorActor will always return the same instance of + * HighlighterActor if asked several times and this instance is used in the + * toolbox to highlighter elements's box-model from the markup-view, + * box model view, console, debugger, ... as well as select elements with the + * pointer (pick). + * + * Other types of highlighter actors exist and can be accessed via the + * InspectorActor's 'getHighlighterByType' method. + */ + +/** + * The HighlighterActor class + */ +var HighlighterActor = exports.HighlighterActor = protocol.ActorClassWithSpec(highlighterSpec, { + initialize: function (inspector, autohide) { + protocol.Actor.prototype.initialize.call(this, null); + + this._autohide = autohide; + this._inspector = inspector; + this._walker = this._inspector.walker; + this._tabActor = this._inspector.tabActor; + this._highlighterEnv = new HighlighterEnvironment(); + this._highlighterEnv.initFromTabActor(this._tabActor); + + this._highlighterReady = this._highlighterReady.bind(this); + this._highlighterHidden = this._highlighterHidden.bind(this); + this._onNavigate = this._onNavigate.bind(this); + + let doc = this._tabActor.window.document; + // Only try to create the highlighter when the document is loaded, + // otherwise, wait for the navigate event to fire. + if (doc.documentElement && doc.readyState != "uninitialized") { + this._createHighlighter(); + } + + // Listen to navigation events to switch from the BoxModelHighlighter to the + // SimpleOutlineHighlighter, and back, if the top level window changes. + events.on(this._tabActor, "navigate", this._onNavigate); + }, + + get conn() { + return this._inspector && this._inspector.conn; + }, + + form: function () { + return { + actor: this.actorID, + traits: { + autoHideOnDestroy: true + } + }; + }, + + _createHighlighter: function () { + this._isPreviousWindowXUL = isXUL(this._tabActor.window); + + if (!this._isPreviousWindowXUL) { + this._highlighter = new BoxModelHighlighter(this._highlighterEnv, + this._inspector); + this._highlighter.on("ready", this._highlighterReady); + this._highlighter.on("hide", this._highlighterHidden); + } else { + this._highlighter = new SimpleOutlineHighlighter(this._highlighterEnv); + } + }, + + _destroyHighlighter: function () { + if (this._highlighter) { + if (!this._isPreviousWindowXUL) { + this._highlighter.off("ready", this._highlighterReady); + this._highlighter.off("hide", this._highlighterHidden); + } + this._highlighter.destroy(); + this._highlighter = null; + } + }, + + _onNavigate: function ({isTopLevel}) { + // Skip navigation events for non top-level windows, or if the document + // doesn't exist anymore. + if (!isTopLevel || !this._tabActor.window.document.documentElement) { + return; + } + + // Only rebuild the highlighter if the window type changed. + if (isXUL(this._tabActor.window) !== this._isPreviousWindowXUL) { + this._destroyHighlighter(); + this._createHighlighter(); + } + }, + + destroy: function () { + protocol.Actor.prototype.destroy.call(this); + + this.hideBoxModel(); + this._destroyHighlighter(); + events.off(this._tabActor, "navigate", this._onNavigate); + + this._highlighterEnv.destroy(); + this._highlighterEnv = null; + + this._autohide = null; + this._inspector = null; + this._walker = null; + this._tabActor = null; + }, + + /** + * Display the box model highlighting on a given NodeActor. + * There is only one instance of the box model highlighter, so calling this + * method several times won't display several highlighters, it will just move + * the highlighter instance to these nodes. + * + * @param NodeActor The node to be highlighted + * @param Options See the request part for existing options. Note that not + * all options may be supported by all types of highlighters. + */ + showBoxModel: function (node, options = {}) { + if (!node || !this._highlighter.show(node.rawNode, options)) { + this._highlighter.hide(); + } + }, + + /** + * Hide the box model highlighting if it was shown before + */ + hideBoxModel: function () { + if (this._highlighter) { + this._highlighter.hide(); + } + }, + + /** + * Returns `true` if the event was dispatched from a window included in + * the current highlighter environment; or if the highlighter environment has + * chrome privileges + * + * The method is specifically useful on B2G, where we do not want that events + * from app or main process are processed if we're inspecting the content. + * + * @param {Event} event + * The event to allow + * @return {Boolean} + */ + _isEventAllowed: function ({view}) { + let { window } = this._highlighterEnv; + + return window instanceof Ci.nsIDOMChromeWindow || + isWindowIncluded(window, view); + }, + + /** + * Pick a node on click, and highlight hovered nodes in the process. + * + * This method doesn't respond anything interesting, however, it starts + * mousemove, and click listeners on the content document to fire + * events and let connected clients know when nodes are hovered over or + * clicked. + * + * Once a node is picked, events will cease, and listeners will be removed. + */ + _isPicking: false, + _hoveredNode: null, + _currentNode: null, + + pick: function () { + if (this._isPicking) { + return null; + } + this._isPicking = true; + + this._preventContentEvent = event => { + event.stopPropagation(); + event.preventDefault(); + }; + + this._onPick = event => { + this._preventContentEvent(event); + + if (!this._isEventAllowed(event)) { + return; + } + + // If shift is pressed, this is only a preview click, send the event to + // the client, but don't stop picking. + if (event.shiftKey) { + events.emit(this._walker, "picker-node-previewed", this._findAndAttachElement(event)); + return; + } + + this._stopPickerListeners(); + this._isPicking = false; + if (this._autohide) { + this._tabActor.window.setTimeout(() => { + this._highlighter.hide(); + }, HIGHLIGHTER_PICKED_TIMER); + } + if (!this._currentNode) { + this._currentNode = this._findAndAttachElement(event); + } + events.emit(this._walker, "picker-node-picked", this._currentNode); + }; + + this._onHovered = event => { + this._preventContentEvent(event); + + if (!this._isEventAllowed(event)) { + return; + } + + this._currentNode = this._findAndAttachElement(event); + if (this._hoveredNode !== this._currentNode.node) { + this._highlighter.show(this._currentNode.node.rawNode); + events.emit(this._walker, "picker-node-hovered", this._currentNode); + this._hoveredNode = this._currentNode.node; + } + }; + + this._onKey = event => { + if (!this._currentNode || !this._isPicking) { + return; + } + + this._preventContentEvent(event); + + if (!this._isEventAllowed(event)) { + return; + } + + let currentNode = this._currentNode.node.rawNode; + + /** + * KEY: Action/scope + * LEFT_KEY: wider or parent + * RIGHT_KEY: narrower or child + * ENTER/CARRIAGE_RETURN: Picks currentNode + * ESC/CTRL+SHIFT+C: Cancels picker, picks currentNode + */ + switch (event.keyCode) { + // Wider. + case Ci.nsIDOMKeyEvent.DOM_VK_LEFT: + if (!currentNode.parentElement) { + return; + } + currentNode = currentNode.parentElement; + break; + + // Narrower. + case Ci.nsIDOMKeyEvent.DOM_VK_RIGHT: + if (!currentNode.children.length) { + return; + } + + // Set firstElementChild by default + let child = currentNode.firstElementChild; + // If currentNode is parent of hoveredNode, then + // previously selected childNode is set + let hoveredNode = this._hoveredNode.rawNode; + for (let sibling of currentNode.children) { + if (sibling.contains(hoveredNode) || sibling === hoveredNode) { + child = sibling; + } + } + + currentNode = child; + break; + + // Select the element. + case Ci.nsIDOMKeyEvent.DOM_VK_RETURN: + this._onPick(event); + return; + + // Cancel pick mode. + case Ci.nsIDOMKeyEvent.DOM_VK_ESCAPE: + this.cancelPick(); + events.emit(this._walker, "picker-node-canceled"); + return; + case Ci.nsIDOMKeyEvent.DOM_VK_C: + if ((IS_OSX && event.metaKey && event.altKey) || + (!IS_OSX && event.ctrlKey && event.shiftKey)) { + this.cancelPick(); + events.emit(this._walker, "picker-node-canceled"); + return; + } + default: return; + } + + // Store currently attached element + this._currentNode = this._walker.attachElement(currentNode); + this._highlighter.show(this._currentNode.node.rawNode); + events.emit(this._walker, "picker-node-hovered", this._currentNode); + }; + + this._startPickerListeners(); + + return null; + }, + + /** + * This pick method also focuses the highlighter's target window. + */ + pickAndFocus: function() { + // Go ahead and pass on the results to help future-proof this method. + let pickResults = this.pick(); + this._highlighterEnv.window.focus(); + return pickResults; + }, + + _findAndAttachElement: function (event) { + // originalTarget allows access to the "real" element before any retargeting + // is applied, such as in the case of XBL anonymous elements. See also + // https://developer.mozilla.org/docs/XBL/XBL_1.0_Reference/Anonymous_Content#Event_Flow_and_Targeting + let node = event.originalTarget || event.target; + return this._walker.attachElement(node); + }, + + _startPickerListeners: function () { + let target = this._highlighterEnv.pageListenerTarget; + target.addEventListener("mousemove", this._onHovered, true); + target.addEventListener("click", this._onPick, true); + target.addEventListener("mousedown", this._preventContentEvent, true); + target.addEventListener("mouseup", this._preventContentEvent, true); + target.addEventListener("dblclick", this._preventContentEvent, true); + target.addEventListener("keydown", this._onKey, true); + target.addEventListener("keyup", this._preventContentEvent, true); + }, + + _stopPickerListeners: function () { + let target = this._highlighterEnv.pageListenerTarget; + target.removeEventListener("mousemove", this._onHovered, true); + target.removeEventListener("click", this._onPick, true); + target.removeEventListener("mousedown", this._preventContentEvent, true); + target.removeEventListener("mouseup", this._preventContentEvent, true); + target.removeEventListener("dblclick", this._preventContentEvent, true); + target.removeEventListener("keydown", this._onKey, true); + target.removeEventListener("keyup", this._preventContentEvent, true); + }, + + _highlighterReady: function () { + events.emit(this._inspector.walker, "highlighter-ready"); + }, + + _highlighterHidden: function () { + events.emit(this._inspector.walker, "highlighter-hide"); + }, + + cancelPick: function () { + if (this._isPicking) { + this._highlighter.hide(); + this._stopPickerListeners(); + this._isPicking = false; + this._hoveredNode = null; + } + } +}); + +/** + * A generic highlighter actor class that instantiate a highlighter given its + * type name and allows to show/hide it. + */ +var CustomHighlighterActor = exports.CustomHighlighterActor = protocol.ActorClassWithSpec(customHighlighterSpec, { + /** + * Create a highlighter instance given its typename + * The typename must be one of HIGHLIGHTER_CLASSES and the class must + * implement constructor(tabActor), show(node), hide(), destroy() + */ + initialize: function (inspector, typeName) { + protocol.Actor.prototype.initialize.call(this, null); + + this._inspector = inspector; + + let constructor = highlighterTypes.get(typeName); + if (!constructor) { + let list = [...highlighterTypes.keys()]; + + throw new Error(`${typeName} isn't a valid highlighter class (${list})`); + } + + // The assumption is that all custom highlighters need the canvasframe + // container to append their elements, so if this is a XUL window, bail out. + if (!isXUL(this._inspector.tabActor.window)) { + this._highlighterEnv = new HighlighterEnvironment(); + this._highlighterEnv.initFromTabActor(inspector.tabActor); + this._highlighter = new constructor(this._highlighterEnv); + } else { + throw new Error("Custom " + typeName + + "highlighter cannot be created in a XUL window"); + } + }, + + get conn() { + return this._inspector && this._inspector.conn; + }, + + destroy: function () { + protocol.Actor.prototype.destroy.call(this); + this.finalize(); + this._inspector = null; + }, + + release: function () {}, + + /** + * Show the highlighter. + * This calls through to the highlighter instance's |show(node, options)| + * method. + * + * Most custom highlighters are made to highlight DOM nodes, hence the first + * NodeActor argument (NodeActor as in + * devtools/server/actor/inspector). + * Note however that some highlighters use this argument merely as a context + * node: the RectHighlighter for instance uses it to calculate the absolute + * position of the provided rect. The SelectHighlighter uses it as a base node + * to run the provided CSS selector on. + * + * @param {NodeActor} The node to be highlighted + * @param {Object} Options for the custom highlighter + * @return {Boolean} True, if the highlighter has been successfully shown + * (FF41+) + */ + show: function (node, options) { + if (!node || !this._highlighter) { + return false; + } + + return this._highlighter.show(node.rawNode, options); + }, + + /** + * Hide the highlighter if it was shown before + */ + hide: function () { + if (this._highlighter) { + this._highlighter.hide(); + } + }, + + /** + * Kill this actor. This method is called automatically just before the actor + * is destroyed. + */ + finalize: function () { + if (this._highlighter) { + this._highlighter.destroy(); + this._highlighter = null; + } + + if (this._highlighterEnv) { + this._highlighterEnv.destroy(); + this._highlighterEnv = null; + } + } +}); + +/** + * The HighlighterEnvironment is an object that holds all the required data for + * highlighters to work: the window, docShell, event listener target, ... + * It also emits "will-navigate" and "navigate" events, similarly to the + * TabActor. + * + * It can be initialized either from a TabActor (which is the most frequent way + * of using it, since highlighters are usually initialized by the + * HighlighterActor or CustomHighlighterActor, which have a tabActor reference). + * It can also be initialized just with a window object (which is useful for + * when a highlighter is used outside of the debugger server context, for + * instance from a gcli command). + */ +function HighlighterEnvironment() { + this.relayTabActorNavigate = this.relayTabActorNavigate.bind(this); + this.relayTabActorWillNavigate = this.relayTabActorWillNavigate.bind(this); + + EventEmitter.decorate(this); +} + +exports.HighlighterEnvironment = HighlighterEnvironment; + +HighlighterEnvironment.prototype = { + initFromTabActor: function (tabActor) { + this._tabActor = tabActor; + events.on(this._tabActor, "navigate", this.relayTabActorNavigate); + events.on(this._tabActor, "will-navigate", this.relayTabActorWillNavigate); + }, + + initFromWindow: function (win) { + this._win = win; + + // We need a progress listener to know when the window will navigate/has + // navigated. + let self = this; + this.listener = { + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference, + Ci.nsISupports + ]), + + onStateChange: function (progress, request, flag) { + let isStart = flag & Ci.nsIWebProgressListener.STATE_START; + let isStop = flag & Ci.nsIWebProgressListener.STATE_STOP; + let isWindow = flag & Ci.nsIWebProgressListener.STATE_IS_WINDOW; + let isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT; + + if (progress.DOMWindow !== win) { + return; + } + + if (isDocument && isStart) { + // One of the earliest events that tells us a new URI is being loaded + // in this window. + self.emit("will-navigate", { + window: win, + isTopLevel: true + }); + } + if (isWindow && isStop) { + self.emit("navigate", { + window: win, + isTopLevel: true + }); + } + } + }; + + this.webProgress.addProgressListener(this.listener, + Ci.nsIWebProgress.NOTIFY_STATUS | + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW | + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT); + }, + + get isInitialized() { + return this._win || this._tabActor; + }, + + get isXUL() { + return isXUL(this.window); + }, + + get window() { + if (!this.isInitialized) { + throw new Error("Initialize HighlighterEnvironment with a tabActor " + + "or window first"); + } + return this._tabActor ? this._tabActor.window : this._win; + }, + + get document() { + return this.window.document; + }, + + get docShell() { + return this.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + }, + + get webProgress() { + return this.docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + }, + + /** + * Get the right target for listening to events on the page. + * - If the environment was initialized from a TabActor *and* if we're in the + * Browser Toolbox (to inspect firefox desktop): the tabActor is the + * RootActor, in which case, the window property can be used to listen to + * events. + * - With firefox desktop, that tabActor is a BrowserTabActor, and with B2G, + * a ContentActor (which overrides BrowserTabActor). In both cases we use + * the chromeEventHandler which gives us a target we can use to listen to + * events, even from nested iframes. + * - If the environment was initialized from a window, we also use the + * chromeEventHandler. + */ + get pageListenerTarget() { + if (this._tabActor && this._tabActor.isRootActor) { + return this.window; + } + return this.docShell.chromeEventHandler; + }, + + relayTabActorNavigate: function (data) { + this.emit("navigate", data); + }, + + relayTabActorWillNavigate: function (data) { + this.emit("will-navigate", data); + }, + + destroy: function () { + if (this._tabActor) { + events.off(this._tabActor, "navigate", this.relayTabActorNavigate); + events.off(this._tabActor, "will-navigate", this.relayTabActorWillNavigate); + } + + // In case the environment was initialized from a window, we need to remove + // the progress listener. + if (this._win) { + try { + this.webProgress.removeProgressListener(this.listener); + } catch (e) { + // Which may fail in case the window was already destroyed. + } + } + + this._tabActor = null; + this._win = null; + } +}; + +const { BoxModelHighlighter } = require("./highlighters/box-model"); +register(BoxModelHighlighter); +exports.BoxModelHighlighter = BoxModelHighlighter; + +const { CssGridHighlighter } = require("./highlighters/css-grid"); +register(CssGridHighlighter); +exports.CssGridHighlighter = CssGridHighlighter; + +const { CssTransformHighlighter } = require("./highlighters/css-transform"); +register(CssTransformHighlighter); +exports.CssTransformHighlighter = CssTransformHighlighter; + +const { SelectorHighlighter } = require("./highlighters/selector"); +register(SelectorHighlighter); +exports.SelectorHighlighter = SelectorHighlighter; + +const { RectHighlighter } = require("./highlighters/rect"); +register(RectHighlighter); +exports.RectHighlighter = RectHighlighter; + +const { GeometryEditorHighlighter } = require("./highlighters/geometry-editor"); +register(GeometryEditorHighlighter); +exports.GeometryEditorHighlighter = GeometryEditorHighlighter; + +const { RulersHighlighter } = require("./highlighters/rulers"); +register(RulersHighlighter); +exports.RulersHighlighter = RulersHighlighter; + +const { MeasuringToolHighlighter } = require("./highlighters/measuring-tool"); +register(MeasuringToolHighlighter); +exports.MeasuringToolHighlighter = MeasuringToolHighlighter; + +const { EyeDropper } = require("./highlighters/eye-dropper"); +register(EyeDropper); +exports.EyeDropper = EyeDropper; diff --git a/devtools/server/actors/highlighters/auto-refresh.js b/devtools/server/actors/highlighters/auto-refresh.js new file mode 100644 index 000000000..31f89de20 --- /dev/null +++ b/devtools/server/actors/highlighters/auto-refresh.js @@ -0,0 +1,215 @@ +/* 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 } = require("chrome"); +const EventEmitter = require("devtools/shared/event-emitter"); +const { isNodeValid } = require("./utils/markup"); +const { getAdjustedQuads } = require("devtools/shared/layout/utils"); + +// Note that the order of items in this array is important because it is used +// for drawing the BoxModelHighlighter's path elements correctly. +const BOX_MODEL_REGIONS = ["margin", "border", "padding", "content"]; + +/** + * Base class for auto-refresh-on-change highlighters. Sub classes will have a + * chance to update whenever the current node's geometry changes. + * + * Sub classes must implement the following methods: + * _show: called when the highlighter should be shown, + * _hide: called when the highlighter should be hidden, + * _update: called while the highlighter is shown and the geometry of the + * current node changes. + * + * Sub classes will have access to the following properties: + * - this.currentNode: the node to be shown + * - this.currentQuads: all of the node's box model region quads + * - this.win: the current window + * + * Emits the following events: + * - shown + * - hidden + * - updated + */ +function AutoRefreshHighlighter(highlighterEnv) { + EventEmitter.decorate(this); + + this.highlighterEnv = highlighterEnv; + + this.currentNode = null; + this.currentQuads = {}; + + this.update = this.update.bind(this); +} + +AutoRefreshHighlighter.prototype = { + /** + * Window corresponding to the current highlighterEnv + */ + get win() { + if (!this.highlighterEnv) { + return null; + } + return this.highlighterEnv.window; + }, + + /** + * Show the highlighter on a given node + * @param {DOMNode} node + * @param {Object} options + * Object used for passing options + */ + show: function (node, options = {}) { + let isSameNode = node === this.currentNode; + let isSameOptions = this._isSameOptions(options); + + if (!this._isNodeValid(node) || (isSameNode && isSameOptions)) { + return false; + } + + this.options = options; + + this._stopRefreshLoop(); + this.currentNode = node; + this._updateAdjustedQuads(); + this._startRefreshLoop(); + + let shown = this._show(); + if (shown) { + this.emit("shown"); + } + return shown; + }, + + /** + * Hide the highlighter + */ + hide: function () { + if (!this._isNodeValid(this.currentNode)) { + return; + } + + this._hide(); + this._stopRefreshLoop(); + this.currentNode = null; + this.currentQuads = {}; + this.options = null; + + this.emit("hidden"); + }, + + /** + * Whether the current node is valid for this highlighter type. + * This is implemented by default to check if the node is an element node. Highlighter + * sub-classes should override this method if they want to highlight other node types. + * @param {DOMNode} node + * @return {Boolean} + */ + _isNodeValid: function (node) { + return isNodeValid(node); + }, + + /** + * Are the provided options the same as the currently stored options? + * Returns false if there are no options stored currently. + */ + _isSameOptions: function (options) { + if (!this.options) { + return false; + } + + let keys = Object.keys(options); + + if (keys.length !== Object.keys(this.options).length) { + return false; + } + + for (let key of keys) { + if (this.options[key] !== options[key]) { + return false; + } + } + + return true; + }, + + /** + * Update the stored box quads by reading the current node's box quads. + */ + _updateAdjustedQuads: function () { + for (let region of BOX_MODEL_REGIONS) { + this.currentQuads[region] = getAdjustedQuads( + this.win, + this.currentNode, region); + } + }, + + /** + * Update the knowledge we have of the current node's boxquads and return true + * if any of the points x/y or bounds have change since. + * @return {Boolean} + */ + _hasMoved: function () { + let oldQuads = JSON.stringify(this.currentQuads); + this._updateAdjustedQuads(); + let newQuads = JSON.stringify(this.currentQuads); + return oldQuads !== newQuads; + }, + + /** + * Update the highlighter if the node has moved since the last update. + */ + update: function () { + if (!this._isNodeValid(this.currentNode) || !this._hasMoved()) { + return; + } + + this._update(); + this.emit("updated"); + }, + + _show: function () { + // To be implemented by sub classes + // When called, sub classes should actually show the highlighter for + // this.currentNode, potentially using options in this.options + throw new Error("Custom highlighter class had to implement _show method"); + }, + + _update: function () { + // To be implemented by sub classes + // When called, sub classes should update the highlighter shown for + // this.currentNode + // This is called as a result of a page scroll, zoom or repaint + throw new Error("Custom highlighter class had to implement _update method"); + }, + + _hide: function () { + // To be implemented by sub classes + // When called, sub classes should actually hide the highlighter + throw new Error("Custom highlighter class had to implement _hide method"); + }, + + _startRefreshLoop: function () { + let win = this.currentNode.ownerDocument.defaultView; + this.rafID = win.requestAnimationFrame(this._startRefreshLoop.bind(this)); + this.rafWin = win; + this.update(); + }, + + _stopRefreshLoop: function () { + if (this.rafID && !Cu.isDeadWrapper(this.rafWin)) { + this.rafWin.cancelAnimationFrame(this.rafID); + } + this.rafID = this.rafWin = null; + }, + + destroy: function () { + this.hide(); + + this.highlighterEnv = null; + this.currentNode = null; + } +}; +exports.AutoRefreshHighlighter = AutoRefreshHighlighter; diff --git a/devtools/server/actors/highlighters/box-model.js b/devtools/server/actors/highlighters/box-model.js new file mode 100644 index 000000000..35f201a04 --- /dev/null +++ b/devtools/server/actors/highlighters/box-model.js @@ -0,0 +1,712 @@ + /* 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 { extend } = require("sdk/core/heritage"); +const { AutoRefreshHighlighter } = require("./auto-refresh"); +const { + CanvasFrameAnonymousContentHelper, + createNode, + createSVGNode, + getBindingElementAndPseudo, + hasPseudoClassLock, + isNodeValid, + moveInfobar, +} = require("./utils/markup"); +const { setIgnoreLayoutChanges } = require("devtools/shared/layout/utils"); +const inspector = require("devtools/server/actors/inspector"); +const nodeConstants = require("devtools/shared/dom-node-constants"); + +// Note that the order of items in this array is important because it is used +// for drawing the BoxModelHighlighter's path elements correctly. +const BOX_MODEL_REGIONS = ["margin", "border", "padding", "content"]; +const BOX_MODEL_SIDES = ["top", "right", "bottom", "left"]; +// Width of boxmodelhighlighter guides +const GUIDE_STROKE_WIDTH = 1; +// FIXME: add ":visited" and ":link" after bug 713106 is fixed +const PSEUDO_CLASSES = [":hover", ":active", ":focus"]; + +/** + * The BoxModelHighlighter draws the box model regions on top of a node. + * If the node is a block box, then each region will be displayed as 1 polygon. + * If the node is an inline box though, each region may be represented by 1 or + * more polygons, depending on how many line boxes the inline element has. + * + * Usage example: + * + * let h = new BoxModelHighlighter(env); + * h.show(node, options); + * h.hide(); + * h.destroy(); + * + * Available options: + * - region {String} + * "content", "padding", "border" or "margin" + * This specifies the region that the guides should outline. + * Defaults to "content" + * - hideGuides {Boolean} + * Defaults to false + * - hideInfoBar {Boolean} + * Defaults to false + * - showOnly {String} + * "content", "padding", "border" or "margin" + * If set, only this region will be highlighted. Use with onlyRegionArea to + * only highlight the area of the region. + * - onlyRegionArea {Boolean} + * This can be set to true to make each region's box only highlight the area + * of the corresponding region rather than the area of nested regions too. + * This is useful when used with showOnly. + * + * Structure: + * <div class="highlighter-container"> + * <div class="box-model-root"> + * <svg class="box-model-elements" hidden="true"> + * <g class="box-model-regions"> + * <path class="box-model-margin" points="..." /> + * <path class="box-model-border" points="..." /> + * <path class="box-model-padding" points="..." /> + * <path class="box-model-content" points="..." /> + * </g> + * <line class="box-model-guide-top" x1="..." y1="..." x2="..." y2="..." /> + * <line class="box-model-guide-right" x1="..." y1="..." x2="..." y2="..." /> + * <line class="box-model-guide-bottom" x1="..." y1="..." x2="..." y2="..." /> + * <line class="box-model-guide-left" x1="..." y1="..." x2="..." y2="..." /> + * </svg> + * <div class="box-model-infobar-container"> + * <div class="box-model-infobar-arrow highlighter-infobar-arrow-top" /> + * <div class="box-model-infobar"> + * <div class="box-model-infobar-text" align="center"> + * <span class="box-model-infobar-tagname">Node name</span> + * <span class="box-model-infobar-id">Node id</span> + * <span class="box-model-infobar-classes">.someClass</span> + * <span class="box-model-infobar-pseudo-classes">:hover</span> + * </div> + * </div> + * <div class="box-model-infobar-arrow box-model-infobar-arrow-bottom"/> + * </div> + * </div> + * </div> + */ +function BoxModelHighlighter(highlighterEnv) { + AutoRefreshHighlighter.call(this, highlighterEnv); + + this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv, + this._buildMarkup.bind(this)); + + /** + * Optionally customize each region's fill color by adding an entry to the + * regionFill property: `highlighter.regionFill.margin = "red"; + */ + this.regionFill = {}; + + this.onWillNavigate = this.onWillNavigate.bind(this); + + this.highlighterEnv.on("will-navigate", this.onWillNavigate); +} + +BoxModelHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, { + typeName: "BoxModelHighlighter", + + ID_CLASS_PREFIX: "box-model-", + + _buildMarkup: function () { + let doc = this.win.document; + + let highlighterContainer = doc.createElement("div"); + highlighterContainer.className = "highlighter-container box-model"; + + // Build the root wrapper, used to adapt to the page zoom. + let rootWrapper = createNode(this.win, { + parent: highlighterContainer, + attributes: { + "id": "root", + "class": "root" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Building the SVG element with its polygons and lines + + let svg = createSVGNode(this.win, { + nodeType: "svg", + parent: rootWrapper, + attributes: { + "id": "elements", + "width": "100%", + "height": "100%", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let regions = createSVGNode(this.win, { + nodeType: "g", + parent: svg, + attributes: { + "class": "regions" + }, + prefix: this.ID_CLASS_PREFIX + }); + + for (let region of BOX_MODEL_REGIONS) { + createSVGNode(this.win, { + nodeType: "path", + parent: regions, + attributes: { + "class": region, + "id": region + }, + prefix: this.ID_CLASS_PREFIX + }); + } + + for (let side of BOX_MODEL_SIDES) { + createSVGNode(this.win, { + nodeType: "line", + parent: svg, + attributes: { + "class": "guide-" + side, + "id": "guide-" + side, + "stroke-width": GUIDE_STROKE_WIDTH + }, + prefix: this.ID_CLASS_PREFIX + }); + } + + // Building the nodeinfo bar markup + + let infobarContainer = createNode(this.win, { + parent: rootWrapper, + attributes: { + "class": "infobar-container", + "id": "infobar-container", + "position": "top", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let infobar = createNode(this.win, { + parent: infobarContainer, + attributes: { + "class": "infobar" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let texthbox = createNode(this.win, { + parent: infobar, + attributes: { + "class": "infobar-text" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: texthbox, + attributes: { + "class": "infobar-tagname", + "id": "infobar-tagname" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: texthbox, + attributes: { + "class": "infobar-id", + "id": "infobar-id" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: texthbox, + attributes: { + "class": "infobar-classes", + "id": "infobar-classes" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: texthbox, + attributes: { + "class": "infobar-pseudo-classes", + "id": "infobar-pseudo-classes" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: texthbox, + attributes: { + "class": "infobar-dimensions", + "id": "infobar-dimensions" + }, + prefix: this.ID_CLASS_PREFIX + }); + + return highlighterContainer; + }, + + /** + * Destroy the nodes. Remove listeners. + */ + destroy: function () { + this.highlighterEnv.off("will-navigate", this.onWillNavigate); + this.markup.destroy(); + AutoRefreshHighlighter.prototype.destroy.call(this); + }, + + getElement: function (id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + /** + * Override the AutoRefreshHighlighter's _isNodeValid method to also return true for + * text nodes since these can also be highlighted. + * @param {DOMNode} node + * @return {Boolean} + */ + _isNodeValid: function (node) { + return node && (isNodeValid(node) || isNodeValid(node, nodeConstants.TEXT_NODE)); + }, + + /** + * Show the highlighter on a given node + */ + _show: function () { + if (BOX_MODEL_REGIONS.indexOf(this.options.region) == -1) { + this.options.region = "content"; + } + + let shown = this._update(); + this._trackMutations(); + this.emit("ready"); + return shown; + }, + + /** + * Track the current node markup mutations so that the node info bar can be + * updated to reflects the node's attributes + */ + _trackMutations: function () { + if (isNodeValid(this.currentNode)) { + let win = this.currentNode.ownerDocument.defaultView; + this.currentNodeObserver = new win.MutationObserver(this.update); + this.currentNodeObserver.observe(this.currentNode, {attributes: true}); + } + }, + + _untrackMutations: function () { + if (isNodeValid(this.currentNode) && this.currentNodeObserver) { + this.currentNodeObserver.disconnect(); + this.currentNodeObserver = null; + } + }, + + /** + * Update the highlighter on the current highlighted node (the one that was + * passed as an argument to show(node)). + * Should be called whenever node size or attributes change + */ + _update: function () { + let shown = false; + setIgnoreLayoutChanges(true); + + if (this._updateBoxModel()) { + // Show the infobar only if configured to do so and the node is an element or a text + // node. + if (!this.options.hideInfoBar && ( + this.currentNode.nodeType === this.currentNode.ELEMENT_NODE || + this.currentNode.nodeType === this.currentNode.TEXT_NODE)) { + this._showInfobar(); + } else { + this._hideInfobar(); + } + this._showBoxModel(); + shown = true; + } else { + // Nothing to highlight (0px rectangle like a <script> tag for instance) + this._hide(); + } + + setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement); + + return shown; + }, + + /** + * Hide the highlighter, the outline and the infobar. + */ + _hide: function () { + setIgnoreLayoutChanges(true); + + this._untrackMutations(); + this._hideBoxModel(); + this._hideInfobar(); + + setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement); + }, + + /** + * Hide the infobar + */ + _hideInfobar: function () { + this.getElement("infobar-container").setAttribute("hidden", "true"); + }, + + /** + * Show the infobar + */ + _showInfobar: function () { + this.getElement("infobar-container").removeAttribute("hidden"); + this._updateInfobar(); + }, + + /** + * Hide the box model + */ + _hideBoxModel: function () { + this.getElement("elements").setAttribute("hidden", "true"); + }, + + /** + * Show the box model + */ + _showBoxModel: function () { + this.getElement("elements").removeAttribute("hidden"); + }, + + /** + * Calculate an outer quad based on the quads returned by getAdjustedQuads. + * The BoxModelHighlighter may highlight more than one boxes, so in this case + * create a new quad that "contains" all of these quads. + * This is useful to position the guides and infobar. + * This may happen if the BoxModelHighlighter is used to highlight an inline + * element that spans line breaks. + * @param {String} region The box-model region to get the outer quad for. + * @return {Object} A quad-like object {p1,p2,p3,p4,bounds} + */ + _getOuterQuad: function (region) { + let quads = this.currentQuads[region]; + if (!quads.length) { + return null; + } + + let quad = { + p1: {x: Infinity, y: Infinity}, + p2: {x: -Infinity, y: Infinity}, + p3: {x: -Infinity, y: -Infinity}, + p4: {x: Infinity, y: -Infinity}, + bounds: { + bottom: -Infinity, + height: 0, + left: Infinity, + right: -Infinity, + top: Infinity, + width: 0, + x: 0, + y: 0, + } + }; + + for (let q of quads) { + quad.p1.x = Math.min(quad.p1.x, q.p1.x); + quad.p1.y = Math.min(quad.p1.y, q.p1.y); + quad.p2.x = Math.max(quad.p2.x, q.p2.x); + quad.p2.y = Math.min(quad.p2.y, q.p2.y); + quad.p3.x = Math.max(quad.p3.x, q.p3.x); + quad.p3.y = Math.max(quad.p3.y, q.p3.y); + quad.p4.x = Math.min(quad.p4.x, q.p4.x); + quad.p4.y = Math.max(quad.p4.y, q.p4.y); + + quad.bounds.bottom = Math.max(quad.bounds.bottom, q.bounds.bottom); + quad.bounds.top = Math.min(quad.bounds.top, q.bounds.top); + quad.bounds.left = Math.min(quad.bounds.left, q.bounds.left); + quad.bounds.right = Math.max(quad.bounds.right, q.bounds.right); + } + quad.bounds.x = quad.bounds.left; + quad.bounds.y = quad.bounds.top; + quad.bounds.width = quad.bounds.right - quad.bounds.left; + quad.bounds.height = quad.bounds.bottom - quad.bounds.top; + + return quad; + }, + + /** + * Update the box model as per the current node. + * + * @return {boolean} + * True if the current node has a box model to be highlighted + */ + _updateBoxModel: function () { + let options = this.options; + options.region = options.region || "content"; + + if (!this._nodeNeedsHighlighting()) { + this._hideBoxModel(); + return false; + } + + for (let i = 0; i < BOX_MODEL_REGIONS.length; i++) { + let boxType = BOX_MODEL_REGIONS[i]; + let nextBoxType = BOX_MODEL_REGIONS[i + 1]; + let box = this.getElement(boxType); + + if (this.regionFill[boxType]) { + box.setAttribute("style", "fill:" + this.regionFill[boxType]); + } else { + box.setAttribute("style", ""); + } + + // Highlight all quads for this region by setting the "d" attribute of the + // corresponding <path>. + let path = []; + for (let j = 0; j < this.currentQuads[boxType].length; j++) { + let boxQuad = this.currentQuads[boxType][j]; + let nextBoxQuad = this.currentQuads[nextBoxType] + ? this.currentQuads[nextBoxType][j] + : null; + path.push(this._getBoxPathCoordinates(boxQuad, nextBoxQuad)); + } + + box.setAttribute("d", path.join(" ")); + box.removeAttribute("faded"); + + // If showOnly is defined, either hide the other regions, or fade them out + // if onlyRegionArea is set too. + if (options.showOnly && options.showOnly !== boxType) { + if (options.onlyRegionArea) { + box.setAttribute("faded", "true"); + } else { + box.removeAttribute("d"); + } + } + + if (boxType === options.region && !options.hideGuides) { + this._showGuides(boxType); + } else if (options.hideGuides) { + this._hideGuides(); + } + } + + // Un-zoom the root wrapper if the page was zoomed. + let rootId = this.ID_CLASS_PREFIX + "root"; + this.markup.scaleRootElement(this.currentNode, rootId); + + return true; + }, + + _getBoxPathCoordinates: function (boxQuad, nextBoxQuad) { + let {p1, p2, p3, p4} = boxQuad; + + let path; + if (!nextBoxQuad || !this.options.onlyRegionArea) { + // If this is the content box (inner-most box) or if we're not being asked + // to highlight only region areas, then draw a simple rectangle. + path = "M" + p1.x + "," + p1.y + " " + + "L" + p2.x + "," + p2.y + " " + + "L" + p3.x + "," + p3.y + " " + + "L" + p4.x + "," + p4.y; + } else { + // Otherwise, just draw the region itself, not a filled rectangle. + let {p1: np1, p2: np2, p3: np3, p4: np4} = nextBoxQuad; + path = "M" + p1.x + "," + p1.y + " " + + "L" + p2.x + "," + p2.y + " " + + "L" + p3.x + "," + p3.y + " " + + "L" + p4.x + "," + p4.y + " " + + "L" + p1.x + "," + p1.y + " " + + "L" + np1.x + "," + np1.y + " " + + "L" + np4.x + "," + np4.y + " " + + "L" + np3.x + "," + np3.y + " " + + "L" + np2.x + "," + np2.y + " " + + "L" + np1.x + "," + np1.y; + } + + return path; + }, + + /** + * Can the current node be highlighted? Does it have quads. + * @return {Boolean} + */ + _nodeNeedsHighlighting: function () { + return this.currentQuads.margin.length || + this.currentQuads.border.length || + this.currentQuads.padding.length || + this.currentQuads.content.length; + }, + + _getOuterBounds: function () { + for (let region of ["margin", "border", "padding", "content"]) { + let quad = this._getOuterQuad(region); + + if (!quad) { + // Invisible element such as a script tag. + break; + } + + let {bottom, height, left, right, top, width, x, y} = quad.bounds; + + if (width > 0 || height > 0) { + return {bottom, height, left, right, top, width, x, y}; + } + } + + return { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0 + }; + }, + + /** + * We only want to show guides for horizontal and vertical edges as this helps + * to line them up. This method finds these edges and displays a guide there. + * @param {String} region The region around which the guides should be shown. + */ + _showGuides: function (region) { + let {p1, p2, p3, p4} = this._getOuterQuad(region); + + let allX = [p1.x, p2.x, p3.x, p4.x].sort((a, b) => a - b); + let allY = [p1.y, p2.y, p3.y, p4.y].sort((a, b) => a - b); + let toShowX = []; + let toShowY = []; + + for (let arr of [allX, allY]) { + for (let i = 0; i < arr.length; i++) { + let val = arr[i]; + + if (i !== arr.lastIndexOf(val)) { + if (arr === allX) { + toShowX.push(val); + } else { + toShowY.push(val); + } + arr.splice(arr.lastIndexOf(val), 1); + } + } + } + + // Move guide into place or hide it if no valid co-ordinate was found. + this._updateGuide("top", toShowY[0]); + this._updateGuide("right", toShowX[1]); + this._updateGuide("bottom", toShowY[1]); + this._updateGuide("left", toShowX[0]); + }, + + _hideGuides: function () { + for (let side of BOX_MODEL_SIDES) { + this.getElement("guide-" + side).setAttribute("hidden", "true"); + } + }, + + /** + * Move a guide to the appropriate position and display it. If no point is + * passed then the guide is hidden. + * + * @param {String} side + * The guide to update + * @param {Integer} point + * x or y co-ordinate. If this is undefined we hide the guide. + */ + _updateGuide: function (side, point = -1) { + let guide = this.getElement("guide-" + side); + + if (point <= 0) { + guide.setAttribute("hidden", "true"); + return false; + } + + if (side === "top" || side === "bottom") { + guide.setAttribute("x1", "0"); + guide.setAttribute("y1", point + ""); + guide.setAttribute("x2", "100%"); + guide.setAttribute("y2", point + ""); + } else { + guide.setAttribute("x1", point + ""); + guide.setAttribute("y1", "0"); + guide.setAttribute("x2", point + ""); + guide.setAttribute("y2", "100%"); + } + + guide.removeAttribute("hidden"); + + return true; + }, + + /** + * Update node information (displayName#id.class) + */ + _updateInfobar: function () { + if (!this.currentNode) { + return; + } + + let {bindingElement: node, pseudo} = + getBindingElementAndPseudo(this.currentNode); + + // Update the tag, id, classes, pseudo-classes and dimensions + let displayName = inspector.getNodeDisplayName(node); + + let id = node.id ? "#" + node.id : ""; + + let classList = (node.classList || []).length + ? "." + [...node.classList].join(".") + : ""; + + let pseudos = this._getPseudoClasses(node).join(""); + if (pseudo) { + // Display :after as ::after + pseudos += ":" + pseudo; + } + + let rect = this._getOuterQuad("border").bounds; + let dim = parseFloat(rect.width.toPrecision(6)) + + " \u00D7 " + + parseFloat(rect.height.toPrecision(6)); + + this.getElement("infobar-tagname").setTextContent(displayName); + this.getElement("infobar-id").setTextContent(id); + this.getElement("infobar-classes").setTextContent(classList); + this.getElement("infobar-pseudo-classes").setTextContent(pseudos); + this.getElement("infobar-dimensions").setTextContent(dim); + + this._moveInfobar(); + }, + + _getPseudoClasses: function (node) { + if (node.nodeType !== nodeConstants.ELEMENT_NODE) { + // hasPseudoClassLock can only be used on Elements. + return []; + } + + return PSEUDO_CLASSES.filter(pseudo => hasPseudoClassLock(node, pseudo)); + }, + + /** + * Move the Infobar to the right place in the highlighter. + */ + _moveInfobar: function () { + let bounds = this._getOuterBounds(); + let container = this.getElement("infobar-container"); + + moveInfobar(container, bounds, this.win); + }, + + onWillNavigate: function ({ isTopLevel }) { + if (isTopLevel) { + this.hide(); + } + } +}); +exports.BoxModelHighlighter = BoxModelHighlighter; diff --git a/devtools/server/actors/highlighters/css-grid.js b/devtools/server/actors/highlighters/css-grid.js new file mode 100644 index 000000000..0ed1ee961 --- /dev/null +++ b/devtools/server/actors/highlighters/css-grid.js @@ -0,0 +1,737 @@ +/* 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 { extend } = require("sdk/core/heritage"); +const { AutoRefreshHighlighter } = require("./auto-refresh"); +const { + CanvasFrameAnonymousContentHelper, + createNode, + createSVGNode, + moveInfobar, +} = require("./utils/markup"); +const { + getCurrentZoom, + setIgnoreLayoutChanges +} = require("devtools/shared/layout/utils"); +const { stringifyGridFragments } = require("devtools/server/actors/utils/css-grid-utils"); + +const CSS_GRID_ENABLED_PREF = "layout.css.grid.enabled"; +const ROWS = "rows"; +const COLUMNS = "cols"; +const GRID_LINES_PROPERTIES = { + "edge": { + lineDash: [0, 0], + strokeStyle: "#4B0082" + }, + "explicit": { + lineDash: [5, 3], + strokeStyle: "#8A2BE2" + }, + "implicit": { + lineDash: [2, 2], + strokeStyle: "#9370DB" + } +}; + +// px +const GRID_GAP_PATTERN_WIDTH = 14; +const GRID_GAP_PATTERN_HEIGHT = 14; +const GRID_GAP_PATTERN_LINE_DASH = [5, 3]; +const GRID_GAP_PATTERN_STROKE_STYLE = "#9370DB"; + +/** + * Cached used by `CssGridHighlighter.getGridGapPattern`. + */ +const gCachedGridPattern = new WeakMap(); +// WeakMap key for the Row grid pattern. +const ROW_KEY = {}; +// WeakMap key for the Column grid pattern. +const COLUMN_KEY = {}; + +/** + * The CssGridHighlighter is the class that overlays a visual grid on top of + * display:grid elements. + * + * Usage example: + * let h = new CssGridHighlighter(env); + * h.show(node, options); + * h.hide(); + * h.destroy(); + * + * Available Options: + * - showGridArea(areaName) + * @param {String} areaName + * Shows the grid area highlight for the given area name. + * - showAllGridAreas + * Shows all the grid area highlights for the current grid. + * - showGridLineNumbers(isShown) + * @param {Boolean} + * Displays the grid line numbers on the grid lines if isShown is true. + * - showInfiniteLines(isShown) + * @param {Boolean} isShown + * Displays an infinite line to represent the grid lines if isShown is true. + * + * Structure: + * <div class="highlighter-container"> + * <canvas id="css-grid-canvas" class="css-grid-canvas"> + * <svg class="css-grid-elements" hidden="true"> + * <g class="css-grid-regions"> + * <path class="css-grid-areas" points="..." /> + * </g> + * </svg> + * <div class="css-grid-infobar-container"> + * <div class="css-grid-infobar"> + * <div class="css-grid-infobar-text"> + * <span class="css-grid-infobar-areaname">Grid Area Name</span> + * <span class="css-grid-infobar-dimensions"Grid Area Dimensions></span> + * </div> + * </div> + * </div> + * </div> + */ +function CssGridHighlighter(highlighterEnv) { + AutoRefreshHighlighter.call(this, highlighterEnv); + + this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv, + this._buildMarkup.bind(this)); + + this.onNavigate = this.onNavigate.bind(this); + this.onWillNavigate = this.onWillNavigate.bind(this); + + this.highlighterEnv.on("navigate", this.onNavigate); + this.highlighterEnv.on("will-navigate", this.onWillNavigate); +} + +CssGridHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, { + typeName: "CssGridHighlighter", + + ID_CLASS_PREFIX: "css-grid-", + + _buildMarkup() { + let container = createNode(this.win, { + attributes: { + "class": "highlighter-container" + } + }); + + // We use a <canvas> element so that we can draw an arbitrary number of lines + // which wouldn't be possible with HTML or SVG without having to insert and remove + // the whole markup on every update. + createNode(this.win, { + parent: container, + nodeType: "canvas", + attributes: { + "id": "canvas", + "class": "canvas", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Build the SVG element + let svg = createSVGNode(this.win, { + nodeType: "svg", + parent: container, + attributes: { + "id": "elements", + "width": "100%", + "height": "100%", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let regions = createSVGNode(this.win, { + nodeType: "g", + parent: svg, + attributes: { + "class": "regions" + }, + prefix: this.ID_CLASS_PREFIX + }); + + createSVGNode(this.win, { + nodeType: "path", + parent: regions, + attributes: { + "class": "areas", + "id": "areas" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Building the grid infobar markup + let infobarContainer = createNode(this.win, { + parent: container, + attributes: { + "class": "infobar-container", + "id": "infobar-container", + "position": "top", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let infobar = createNode(this.win, { + parent: infobarContainer, + attributes: { + "class": "infobar" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let textbox = createNode(this.win, { + parent: infobar, + attributes: { + "class": "infobar-text" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: textbox, + attributes: { + "class": "infobar-areaname", + "id": "infobar-areaname" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: textbox, + attributes: { + "class": "infobar-dimensions", + "id": "infobar-dimensions" + }, + prefix: this.ID_CLASS_PREFIX + }); + + return container; + }, + + destroy() { + this.highlighterEnv.off("navigate", this.onNavigate); + this.highlighterEnv.off("will-navigate", this.onWillNavigate); + this.markup.destroy(); + + // Clear the pattern cache to avoid dead object exceptions (Bug 1342051). + gCachedGridPattern.delete(ROW_KEY); + gCachedGridPattern.delete(COLUMN_KEY); + + AutoRefreshHighlighter.prototype.destroy.call(this); + }, + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + get ctx() { + return this.canvas.getCanvasContext("2d"); + }, + + get canvas() { + return this.getElement("canvas"); + }, + + /** + * Gets the grid gap pattern used to render the gap regions. + * + * @param {Object} dimension + * Refers to the WeakMap key for the grid dimension type which is either the + * constant COLUMN or ROW. + * @return {CanvasPattern} grid gap pattern. + */ + getGridGapPattern(dimension) { + if (gCachedGridPattern.has(dimension)) { + return gCachedGridPattern.get(dimension); + } + + // Create the diagonal lines pattern for the rendering the grid gaps. + let canvas = createNode(this.win, { nodeType: "canvas" }); + canvas.width = GRID_GAP_PATTERN_WIDTH; + canvas.height = GRID_GAP_PATTERN_HEIGHT; + + let ctx = canvas.getContext("2d"); + ctx.setLineDash(GRID_GAP_PATTERN_LINE_DASH); + ctx.beginPath(); + ctx.translate(.5, .5); + + if (dimension === COLUMN_KEY) { + ctx.moveTo(0, 0); + ctx.lineTo(GRID_GAP_PATTERN_WIDTH, GRID_GAP_PATTERN_HEIGHT); + } else { + ctx.moveTo(GRID_GAP_PATTERN_WIDTH, 0); + ctx.lineTo(0, GRID_GAP_PATTERN_HEIGHT); + } + + ctx.strokeStyle = GRID_GAP_PATTERN_STROKE_STYLE; + ctx.stroke(); + + let pattern = ctx.createPattern(canvas, "repeat"); + gCachedGridPattern.set(dimension, pattern); + return pattern; + }, + + /** + * Called when the page navigates. Used to clear the cached gap patterns and avoid + * using DeadWrapper objects as gap patterns the next time. + */ + onNavigate() { + gCachedGridPattern.delete(ROW_KEY); + gCachedGridPattern.delete(COLUMN_KEY); + }, + + onWillNavigate({ isTopLevel }) { + if (isTopLevel) { + this.hide(); + } + }, + + _show() { + if (Services.prefs.getBoolPref(CSS_GRID_ENABLED_PREF) && !this.isGrid()) { + this.hide(); + return false; + } + + return this._update(); + }, + + /** + * Shows the grid area highlight for the given area name. + * + * @param {String} areaName + * Grid area name. + */ + showGridArea(areaName) { + this.renderGridArea(areaName); + this._showGridArea(); + }, + + /** + * Shows all the grid area highlights for the current grid. + */ + showAllGridAreas() { + this.renderGridArea(); + this._showGridArea(); + }, + + /** + * Clear the grid area highlights. + */ + clearGridAreas() { + let box = this.getElement("areas"); + box.setAttribute("d", ""); + }, + + /** + * Checks if the current node has a CSS Grid layout. + * + * @return {Boolean} true if the current node has a CSS grid layout, false otherwise. + */ + isGrid() { + return this.currentNode.getGridFragments().length > 0; + }, + + /** + * The AutoRefreshHighlighter's _hasMoved method returns true only if the + * element's quads have changed. Override it so it also returns true if the + * element's grid has changed (which can happen when you change the + * grid-template-* CSS properties with the highlighter displayed). + */ + _hasMoved() { + let hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this); + + let oldGridData = stringifyGridFragments(this.gridData); + this.gridData = this.currentNode.getGridFragments(); + let newGridData = stringifyGridFragments(this.gridData); + + return hasMoved || oldGridData !== newGridData; + }, + + /** + * Update the highlighter on the current highlighted node (the one that was + * passed as an argument to show(node)). + * Should be called whenever node's geometry or grid changes. + */ + _update() { + setIgnoreLayoutChanges(true); + + // Clear the canvas the grid area highlights. + this.clearCanvas(); + this.clearGridAreas(); + + // Start drawing the grid fragments. + for (let i = 0; i < this.gridData.length; i++) { + let fragment = this.gridData[i]; + let quad = this.currentQuads.content[i]; + this.renderFragment(fragment, quad); + } + + // Display the grid area highlights if needed. + if (this.options.showAllGridAreas) { + this.showAllGridAreas(); + } else if (this.options.showGridArea) { + this.showGridArea(this.options.showGridArea); + } + + this._showGrid(); + + setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement); + return true; + }, + + /** + * Update the grid information displayed in the grid info bar. + * + * @param {GridArea} area + * The grid area object. + * @param {Number} x1 + * The first x-coordinate of the grid area rectangle. + * @param {Number} x2 + * The second x-coordinate of the grid area rectangle. + * @param {Number} y1 + * The first y-coordinate of the grid area rectangle. + * @param {Number} y2 + * The second y-coordinate of the grid area rectangle. + */ + _updateInfobar(area, x1, x2, y1, y2) { + let width = x2 - x1; + let height = y2 - y1; + let dim = parseFloat(width.toPrecision(6)) + + " \u00D7 " + + parseFloat(height.toPrecision(6)); + + this.getElement("infobar-areaname").setTextContent(area.name); + this.getElement("infobar-dimensions").setTextContent(dim); + + this._moveInfobar(x1, x2, y1, y2); + }, + + /** + * Move the grid infobar to the right place in the highlighter. + * + * @param {Number} x1 + * The first x-coordinate of the grid area rectangle. + * @param {Number} x2 + * The second x-coordinate of the grid area rectangle. + * @param {Number} y1 + * The first y-coordinate of the grid area rectangle. + * @param {Number} y2 + * The second y-coordinate of the grid area rectangle. + */ + _moveInfobar(x1, x2, y1, y2) { + let bounds = { + bottom: y2, + height: y2 - y1, + left: x1, + right: x2, + top: y1, + width: x2 - x1, + x: x1, + y: y1, + }; + let container = this.getElement("infobar-container"); + + moveInfobar(container, bounds, this.win); + }, + + clearCanvas() { + let ratio = parseFloat((this.win.devicePixelRatio || 1).toFixed(2)); + let width = this.win.innerWidth; + let height = this.win.innerHeight; + + // Resize the canvas taking the dpr into account so as to have crisp lines. + this.canvas.setAttribute("width", width * ratio); + this.canvas.setAttribute("height", height * ratio); + this.canvas.setAttribute("style", `width:${width}px;height:${height}px`); + this.ctx.scale(ratio, ratio); + + this.ctx.clearRect(0, 0, width, height); + }, + + getFirstRowLinePos(fragment) { + return fragment.rows.lines[0].start; + }, + + getLastRowLinePos(fragment) { + return fragment.rows.lines[fragment.rows.lines.length - 1].start; + }, + + getFirstColLinePos(fragment) { + return fragment.cols.lines[0].start; + }, + + getLastColLinePos(fragment) { + return fragment.cols.lines[fragment.cols.lines.length - 1].start; + }, + + /** + * Get the GridLine index of the last edge of the explicit grid for a grid dimension. + * + * @param {GridTracks} tracks + * The grid track of a given grid dimension. + * @return {Number} index of the last edge of the explicit grid for a grid dimension. + */ + getLastEdgeLineIndex(tracks) { + let trackIndex = tracks.length - 1; + + // Traverse the grid track backwards until we find an explicit track. + while (trackIndex >= 0 && tracks[trackIndex].type != "explicit") { + trackIndex--; + } + + // The grid line index is the grid track index + 1. + return trackIndex + 1; + }, + + renderFragment(fragment, quad) { + this.renderLines(fragment.cols, quad, COLUMNS, "left", "top", "height", + this.getFirstRowLinePos(fragment), + this.getLastRowLinePos(fragment)); + this.renderLines(fragment.rows, quad, ROWS, "top", "left", "width", + this.getFirstColLinePos(fragment), + this.getLastColLinePos(fragment)); + }, + + /** + * Render the grid lines given the grid dimension information of the + * column or row lines. + * + * @param {GridDimension} gridDimension + * Column or row grid dimension object. + * @param {Object} quad.bounds + * The content bounds of the box model region quads. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + * @param {String} mainSide + * The main side of the given grid dimension - "top" for rows and + * "left" for columns. + * @param {String} crossSide + * The cross side of the given grid dimension - "left" for rows and + * "top" for columns. + * @param {String} mainSize + * The main size of the given grid dimension - "width" for rows and + * "height" for columns. + * @param {Number} startPos + * The start position of the cross side of the grid dimension. + * @param {Number} endPos + * The end position of the cross side of the grid dimension. + */ + renderLines(gridDimension, {bounds}, dimensionType, mainSide, crossSide, + mainSize, startPos, endPos) { + let lineStartPos = (bounds[crossSide] / getCurrentZoom(this.win)) + startPos; + let lineEndPos = (bounds[crossSide] / getCurrentZoom(this.win)) + endPos; + + if (this.options.showInfiniteLines) { + lineStartPos = 0; + lineEndPos = parseInt(this.canvas.getAttribute(mainSize), 10); + } + + let lastEdgeLineIndex = this.getLastEdgeLineIndex(gridDimension.tracks); + + for (let i = 0; i < gridDimension.lines.length; i++) { + let line = gridDimension.lines[i]; + let linePos = (bounds[mainSide] / getCurrentZoom(this.win)) + line.start; + + if (this.options.showGridLineNumbers) { + this.renderGridLineNumber(line.number, linePos, lineStartPos, dimensionType); + } + + if (i == 0 || i == lastEdgeLineIndex) { + this.renderLine(linePos, lineStartPos, lineEndPos, dimensionType, "edge"); + } else { + this.renderLine(linePos, lineStartPos, lineEndPos, dimensionType, + gridDimension.tracks[i - 1].type); + } + + // Render a second line to illustrate the gutter for non-zero breadth. + if (line.breadth > 0) { + this.renderGridGap(linePos, lineStartPos, lineEndPos, line.breadth, + dimensionType); + this.renderLine(linePos + line.breadth, lineStartPos, lineEndPos, dimensionType, + gridDimension.tracks[i].type); + } + } + }, + + /** + * Render the grid line on the css grid highlighter canvas. + * + * @param {Number} linePos + * The line position along the x-axis for a column grid line and + * y-axis for a row grid line. + * @param {Number} startPos + * The start position of the cross side of the grid line. + * @param {Number} endPos + * The end position of the cross side of the grid line. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + * @param {[type]} lineType + * The grid line type - "edge", "explicit", or "implicit". + */ + renderLine(linePos, startPos, endPos, dimensionType, lineType) { + this.ctx.save(); + this.ctx.setLineDash(GRID_LINES_PROPERTIES[lineType].lineDash); + this.ctx.beginPath(); + this.ctx.translate(.5, .5); + + if (dimensionType === COLUMNS) { + this.ctx.moveTo(linePos, startPos); + this.ctx.lineTo(linePos, endPos); + } else { + this.ctx.moveTo(startPos, linePos); + this.ctx.lineTo(endPos, linePos); + } + + this.ctx.strokeStyle = GRID_LINES_PROPERTIES[lineType].strokeStyle; + this.ctx.stroke(); + this.ctx.restore(); + }, + + /** + * Render the grid line number on the css grid highlighter canvas. + * + * @param {Number} lineNumber + * The grid line number. + * @param {Number} linePos + * The line position along the x-axis for a column grid line and + * y-axis for a row grid line. + * @param {Number} startPos + * The start position of the cross side of the grid line. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + */ + renderGridLineNumber(lineNumber, linePos, startPos, dimensionType) { + this.ctx.save(); + + if (dimensionType === COLUMNS) { + this.ctx.fillText(lineNumber, linePos, startPos); + } else { + let textWidth = this.ctx.measureText(lineNumber).width; + this.ctx.fillText(lineNumber, startPos - textWidth, linePos); + } + + this.ctx.restore(); + }, + + /** + * Render the grid gap area on the css grid highlighter canvas. + * + * @param {Number} linePos + * The line position along the x-axis for a column grid line and + * y-axis for a row grid line. + * @param {Number} startPos + * The start position of the cross side of the grid line. + * @param {Number} endPos + * The end position of the cross side of the grid line. + * @param {Number} breadth + * The grid line breadth value. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + */ + renderGridGap(linePos, startPos, endPos, breadth, dimensionType) { + this.ctx.save(); + + if (dimensionType === COLUMNS) { + this.ctx.fillStyle = this.getGridGapPattern(COLUMN_KEY); + this.ctx.fillRect(linePos, startPos, breadth, endPos - startPos); + } else { + this.ctx.fillStyle = this.getGridGapPattern(ROW_KEY); + this.ctx.fillRect(startPos, linePos, endPos - startPos, breadth); + } + + this.ctx.restore(); + }, + + /** + * Render the grid area highlight for the given area name or for all the grid areas. + * + * @param {String} areaName + * Name of the grid area to be highlighted. If no area name is provided, all + * the grid areas should be highlighted. + */ + renderGridArea(areaName) { + let paths = []; + let currentZoom = getCurrentZoom(this.win); + + for (let i = 0; i < this.gridData.length; i++) { + let fragment = this.gridData[i]; + let {bounds} = this.currentQuads.content[i]; + + for (let area of fragment.areas) { + if (areaName && areaName != area.name) { + continue; + } + + let rowStart = fragment.rows.lines[area.rowStart - 1]; + let rowEnd = fragment.rows.lines[area.rowEnd - 1]; + let columnStart = fragment.cols.lines[area.columnStart - 1]; + let columnEnd = fragment.cols.lines[area.columnEnd - 1]; + + let x1 = columnStart.start + columnStart.breadth + + (bounds.left / currentZoom); + let x2 = columnEnd.start + (bounds.left / currentZoom); + let y1 = rowStart.start + rowStart.breadth + + (bounds.top / currentZoom); + let y2 = rowEnd.start + (bounds.top / currentZoom); + + let path = "M" + x1 + "," + y1 + " " + + "L" + x2 + "," + y1 + " " + + "L" + x2 + "," + y2 + " " + + "L" + x1 + "," + y2; + paths.push(path); + + // Update and show the info bar when only displaying a single grid area. + if (areaName) { + this._updateInfobar(area, x1, x2, y1, y2); + this._showInfoBar(); + } + } + } + + let box = this.getElement("areas"); + box.setAttribute("d", paths.join(" ")); + }, + + /** + * Hide the highlighter, the canvas and the infobar. + */ + _hide() { + setIgnoreLayoutChanges(true); + this._hideGrid(); + this._hideGridArea(); + this._hideInfoBar(); + setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement); + }, + + _hideGrid() { + this.getElement("canvas").setAttribute("hidden", "true"); + }, + + _showGrid() { + this.getElement("canvas").removeAttribute("hidden"); + }, + + _hideGridArea() { + this.getElement("elements").setAttribute("hidden", "true"); + }, + + _showGridArea() { + this.getElement("elements").removeAttribute("hidden"); + }, + + _hideInfoBar() { + this.getElement("infobar-container").setAttribute("hidden", "true"); + }, + + _showInfoBar() { + this.getElement("infobar-container").removeAttribute("hidden"); + }, + +}); + +exports.CssGridHighlighter = CssGridHighlighter; diff --git a/devtools/server/actors/highlighters/css-transform.js b/devtools/server/actors/highlighters/css-transform.js new file mode 100644 index 000000000..83a2c7e59 --- /dev/null +++ b/devtools/server/actors/highlighters/css-transform.js @@ -0,0 +1,243 @@ +/* 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 { extend } = require("sdk/core/heritage"); +const { AutoRefreshHighlighter } = require("./auto-refresh"); +const { + CanvasFrameAnonymousContentHelper, getComputedStyle, + createSVGNode, createNode } = require("./utils/markup"); +const { setIgnoreLayoutChanges, + getNodeBounds } = require("devtools/shared/layout/utils"); + +// The minimum distance a line should be before it has an arrow marker-end +const ARROW_LINE_MIN_DISTANCE = 10; + +var MARKER_COUNTER = 1; + +/** + * The CssTransformHighlighter is the class that draws an outline around a + * transformed element and an outline around where it would be if untransformed + * as well as arrows connecting the 2 outlines' corners. + */ +function CssTransformHighlighter(highlighterEnv) { + AutoRefreshHighlighter.call(this, highlighterEnv); + + this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv, + this._buildMarkup.bind(this)); +} + +CssTransformHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, { + typeName: "CssTransformHighlighter", + + ID_CLASS_PREFIX: "css-transform-", + + _buildMarkup: function () { + let container = createNode(this.win, { + attributes: { + "class": "highlighter-container" + } + }); + + // The root wrapper is used to unzoom the highlighter when needed. + let rootWrapper = createNode(this.win, { + parent: container, + attributes: { + "id": "root", + "class": "root" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let svg = createSVGNode(this.win, { + nodeType: "svg", + parent: rootWrapper, + attributes: { + "id": "elements", + "hidden": "true", + "width": "100%", + "height": "100%" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Add a marker tag to the svg root for the arrow tip + this.markerId = "arrow-marker-" + MARKER_COUNTER; + MARKER_COUNTER++; + let marker = createSVGNode(this.win, { + nodeType: "marker", + parent: svg, + attributes: { + "id": this.markerId, + "markerWidth": "10", + "markerHeight": "5", + "orient": "auto", + "markerUnits": "strokeWidth", + "refX": "10", + "refY": "5", + "viewBox": "0 0 10 10" + }, + prefix: this.ID_CLASS_PREFIX + }); + createSVGNode(this.win, { + nodeType: "path", + parent: marker, + attributes: { + "d": "M 0 0 L 10 5 L 0 10 z", + "fill": "#08C" + } + }); + + let shapesGroup = createSVGNode(this.win, { + nodeType: "g", + parent: svg + }); + + // Create the 2 polygons (transformed and untransformed) + createSVGNode(this.win, { + nodeType: "polygon", + parent: shapesGroup, + attributes: { + "id": "untransformed", + "class": "untransformed" + }, + prefix: this.ID_CLASS_PREFIX + }); + createSVGNode(this.win, { + nodeType: "polygon", + parent: shapesGroup, + attributes: { + "id": "transformed", + "class": "transformed" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Create the arrows + for (let nb of ["1", "2", "3", "4"]) { + createSVGNode(this.win, { + nodeType: "line", + parent: shapesGroup, + attributes: { + "id": "line" + nb, + "class": "line", + "marker-end": "url(#" + this.markerId + ")" + }, + prefix: this.ID_CLASS_PREFIX + }); + } + + return container; + }, + + /** + * Destroy the nodes. Remove listeners. + */ + destroy: function () { + AutoRefreshHighlighter.prototype.destroy.call(this); + this.markup.destroy(); + }, + + getElement: function (id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + /** + * Show the highlighter on a given node + */ + _show: function () { + if (!this._isTransformed(this.currentNode)) { + this.hide(); + return false; + } + + return this._update(); + }, + + /** + * Checks if the supplied node is transformed and not inline + */ + _isTransformed: function (node) { + let style = getComputedStyle(node); + return style && (style.transform !== "none" && style.display !== "inline"); + }, + + _setPolygonPoints: function (quad, id) { + let points = []; + for (let point of ["p1", "p2", "p3", "p4"]) { + points.push(quad[point].x + "," + quad[point].y); + } + this.getElement(id).setAttribute("points", points.join(" ")); + }, + + _setLinePoints: function (p1, p2, id) { + let line = this.getElement(id); + line.setAttribute("x1", p1.x); + line.setAttribute("y1", p1.y); + line.setAttribute("x2", p2.x); + line.setAttribute("y2", p2.y); + + let dist = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); + if (dist < ARROW_LINE_MIN_DISTANCE) { + line.removeAttribute("marker-end"); + } else { + line.setAttribute("marker-end", "url(#" + this.markerId + ")"); + } + }, + + /** + * Update the highlighter on the current highlighted node (the one that was + * passed as an argument to show(node)). + * Should be called whenever node size or attributes change + */ + _update: function () { + setIgnoreLayoutChanges(true); + + // Getting the points for the transformed shape + let quads = this.currentQuads.border; + if (!quads.length || + quads[0].bounds.width <= 0 || quads[0].bounds.height <= 0) { + this._hideShapes(); + return false; + } + + let [quad] = quads; + + // Getting the points for the untransformed shape + let untransformedQuad = getNodeBounds(this.win, this.currentNode); + + this._setPolygonPoints(quad, "transformed"); + this._setPolygonPoints(untransformedQuad, "untransformed"); + for (let nb of ["1", "2", "3", "4"]) { + this._setLinePoints(untransformedQuad["p" + nb], quad["p" + nb], "line" + nb); + } + + // Adapt to the current zoom + this.markup.scaleRootElement(this.currentNode, this.ID_CLASS_PREFIX + "root"); + + this._showShapes(); + + setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement); + return true; + }, + + /** + * Hide the highlighter, the outline and the infobar. + */ + _hide: function () { + setIgnoreLayoutChanges(true); + this._hideShapes(); + setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement); + }, + + _hideShapes: function () { + this.getElement("elements").setAttribute("hidden", "true"); + }, + + _showShapes: function () { + this.getElement("elements").removeAttribute("hidden"); + } +}); +exports.CssTransformHighlighter = CssTransformHighlighter; diff --git a/devtools/server/actors/highlighters/eye-dropper.js b/devtools/server/actors/highlighters/eye-dropper.js new file mode 100644 index 000000000..a90ec22bd --- /dev/null +++ b/devtools/server/actors/highlighters/eye-dropper.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"; + +// Eye-dropper tool. This is implemented as a highlighter so it can be displayed in the +// content page. +// It basically displays a magnifier that tracks mouse moves and shows a magnified version +// of the page. On click, it samples the color at the pixel being hovered. + +const {Ci, Cc} = require("chrome"); +const {CanvasFrameAnonymousContentHelper, createNode} = require("./utils/markup"); +const Services = require("Services"); +const EventEmitter = require("devtools/shared/event-emitter"); +const {rgbToHsl, rgbToColorName} = require("devtools/shared/css/color").colorUtils; +const {getCurrentZoom, getFrameOffsets} = require("devtools/shared/layout/utils"); + +loader.lazyGetter(this, "clipboardHelper", + () => Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper)); +loader.lazyGetter(this, "l10n", + () => Services.strings.createBundle("chrome://devtools/locale/eyedropper.properties")); + +const ZOOM_LEVEL_PREF = "devtools.eyedropper.zoom"; +const FORMAT_PREF = "devtools.defaultColorUnit"; +// Width of the canvas. +const MAGNIFIER_WIDTH = 96; +// Height of the canvas. +const MAGNIFIER_HEIGHT = 96; +// Start position, when the tool is first shown. This should match the top/left position +// defined in CSS. +const DEFAULT_START_POS_X = 100; +const DEFAULT_START_POS_Y = 100; +// How long to wait before closing after copy. +const CLOSE_DELAY = 750; + +/** + * The EyeDropper is the class that draws the gradient line and + * color stops as an overlay on top of a linear-gradient background-image. + */ +function EyeDropper(highlighterEnv) { + EventEmitter.decorate(this); + + this.highlighterEnv = highlighterEnv; + this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv, + this._buildMarkup.bind(this)); + + // Get a couple of settings from prefs. + this.format = Services.prefs.getCharPref(FORMAT_PREF); + this.eyeDropperZoomLevel = Services.prefs.getIntPref(ZOOM_LEVEL_PREF); +} + +EyeDropper.prototype = { + typeName: "EyeDropper", + + ID_CLASS_PREFIX: "eye-dropper-", + + get win() { + return this.highlighterEnv.window; + }, + + _buildMarkup() { + // Highlighter main container. + let container = createNode(this.win, { + attributes: {"class": "highlighter-container"} + }); + + // Wrapper element. + let wrapper = createNode(this.win, { + parent: container, + attributes: { + "id": "root", + "class": "root", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // The magnifier canvas element. + createNode(this.win, { + parent: wrapper, + nodeType: "canvas", + attributes: { + "id": "canvas", + "class": "canvas", + "width": MAGNIFIER_WIDTH, + "height": MAGNIFIER_HEIGHT + }, + prefix: this.ID_CLASS_PREFIX + }); + + // The color label element. + let colorLabelContainer = createNode(this.win, { + parent: wrapper, + attributes: {"class": "color-container"}, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "div", + parent: colorLabelContainer, + attributes: {"id": "color-preview", "class": "color-preview"}, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "div", + parent: colorLabelContainer, + attributes: {"id": "color-value", "class": "color-value"}, + prefix: this.ID_CLASS_PREFIX + }); + + return container; + }, + + destroy() { + this.hide(); + this.markup.destroy(); + }, + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + /** + * Show the eye-dropper highlighter. + * @param {DOMNode} node The node which document the highlighter should be inserted in. + * @param {Object} options The options object may contain the following properties: + * - {Boolean} copyOnSelect Whether selecting a color should copy it to the clipboard. + */ + show(node, options = {}) { + if (this.highlighterEnv.isXUL) { + return false; + } + + this.options = options; + + // Get the page's current zoom level. + this.pageZoom = getCurrentZoom(this.win); + + // Take a screenshot of the viewport. This needs to be done first otherwise the + // eyedropper UI will appear in the screenshot itself (since the UI is injected as + // native anonymous content in the page). + // Once the screenshot is ready, the magnified area will be drawn. + this.prepareImageCapture(); + + // Start listening for user events. + let {pageListenerTarget} = this.highlighterEnv; + pageListenerTarget.addEventListener("mousemove", this); + pageListenerTarget.addEventListener("click", this, true); + pageListenerTarget.addEventListener("keydown", this); + pageListenerTarget.addEventListener("DOMMouseScroll", this); + pageListenerTarget.addEventListener("FullZoomChange", this); + + // Show the eye-dropper. + this.getElement("root").removeAttribute("hidden"); + + // Prepare the canvas context on which we're drawing the magnified page portion. + this.ctx = this.getElement("canvas").getCanvasContext(); + this.ctx.imageSmoothingEnabled = false; + + this.magnifiedArea = {width: MAGNIFIER_WIDTH, height: MAGNIFIER_HEIGHT, + x: DEFAULT_START_POS_X, y: DEFAULT_START_POS_Y}; + + this.moveTo(DEFAULT_START_POS_X, DEFAULT_START_POS_Y); + + // Focus the content so the keyboard can be used. + this.win.focus(); + + return true; + }, + + /** + * Hide the eye-dropper highlighter. + */ + hide() { + if (this.highlighterEnv.isXUL) { + return; + } + + this.pageImage = null; + + let {pageListenerTarget} = this.highlighterEnv; + pageListenerTarget.removeEventListener("mousemove", this); + pageListenerTarget.removeEventListener("click", this, true); + pageListenerTarget.removeEventListener("keydown", this); + pageListenerTarget.removeEventListener("DOMMouseScroll", this); + pageListenerTarget.removeEventListener("FullZoomChange", this); + + this.getElement("root").setAttribute("hidden", "true"); + this.getElement("root").removeAttribute("drawn"); + + this.emit("hidden"); + }, + + prepareImageCapture() { + // Get the image data from the content window. + let imageData = getWindowAsImageData(this.win); + + // We need to transform imageData to something drawWindow will consume. An ImageBitmap + // works well. We could have used an Image, but doing so results in errors if the page + // defines CSP headers. + this.win.createImageBitmap(imageData).then(image => { + this.pageImage = image; + // We likely haven't drawn anything yet (no mousemove events yet), so start now. + this.draw(); + + // Set an attribute on the root element to be able to run tests after the first draw + // was done. + this.getElement("root").setAttribute("drawn", "true"); + }); + }, + + /** + * Get the number of cells (blown-up pixels) per direction in the grid. + */ + get cellsWide() { + // Canvas will render whole "pixels" (cells) only, and an even number at that. Round + // up to the nearest even number of pixels. + let cellsWide = Math.ceil(this.magnifiedArea.width / this.eyeDropperZoomLevel); + cellsWide += cellsWide % 2; + + return cellsWide; + }, + + /** + * Get the size of each cell (blown-up pixel) in the grid. + */ + get cellSize() { + return this.magnifiedArea.width / this.cellsWide; + }, + + /** + * Get index of cell in the center of the grid. + */ + get centerCell() { + return Math.floor(this.cellsWide / 2); + }, + + /** + * Get color of center cell in the grid. + */ + get centerColor() { + let pos = (this.centerCell * this.cellSize) + (this.cellSize / 2); + let rgb = this.ctx.getImageData(pos, pos, 1, 1).data; + return rgb; + }, + + draw() { + // If the image of the page isn't ready yet, bail out, we'll draw later on mousemove. + if (!this.pageImage) { + return; + } + + let {width, height, x, y} = this.magnifiedArea; + + let zoomedWidth = width / this.eyeDropperZoomLevel; + let zoomedHeight = height / this.eyeDropperZoomLevel; + + let sx = x - (zoomedWidth / 2); + let sy = y - (zoomedHeight / 2); + let sw = zoomedWidth; + let sh = zoomedHeight; + + this.ctx.drawImage(this.pageImage, sx, sy, sw, sh, 0, 0, width, height); + + // Draw the grid on top, but only at 3x or more, otherwise it's too busy. + if (this.eyeDropperZoomLevel > 2) { + this.drawGrid(); + } + + this.drawCrosshair(); + + // Update the color preview and value. + let rgb = this.centerColor; + this.getElement("color-preview").setAttribute("style", + `background-color:${toColorString(rgb, "rgb")};`); + this.getElement("color-value").setTextContent(toColorString(rgb, this.format)); + }, + + /** + * Draw a grid on the canvas representing pixel boundaries. + */ + drawGrid() { + let {width, height} = this.magnifiedArea; + + this.ctx.lineWidth = 1; + this.ctx.strokeStyle = "rgba(143, 143, 143, 0.2)"; + + for (let i = 0; i < width; i += this.cellSize) { + this.ctx.beginPath(); + this.ctx.moveTo(i - .5, 0); + this.ctx.lineTo(i - .5, height); + this.ctx.stroke(); + + this.ctx.beginPath(); + this.ctx.moveTo(0, i - .5); + this.ctx.lineTo(width, i - .5); + this.ctx.stroke(); + } + }, + + /** + * Draw a box on the canvas to highlight the center cell. + */ + drawCrosshair() { + let pos = this.centerCell * this.cellSize; + + this.ctx.lineWidth = 1; + this.ctx.lineJoin = "miter"; + this.ctx.strokeStyle = "rgba(0, 0, 0, 1)"; + this.ctx.strokeRect(pos - 1.5, pos - 1.5, this.cellSize + 2, this.cellSize + 2); + + this.ctx.strokeStyle = "rgba(255, 255, 255, 1)"; + this.ctx.strokeRect(pos - 0.5, pos - 0.5, this.cellSize, this.cellSize); + }, + + handleEvent(e) { + switch (e.type) { + case "mousemove": + // We might be getting an event from a child frame, so account for the offset. + let [xOffset, yOffset] = getFrameOffsets(this.win, e.target); + let x = xOffset + e.pageX - this.win.scrollX; + let y = yOffset + e.pageY - this.win.scrollY; + // Update the zoom area. + this.magnifiedArea.x = x * this.pageZoom; + this.magnifiedArea.y = y * this.pageZoom; + // Redraw the portion of the screenshot that is now under the mouse. + this.draw(); + // And move the eye-dropper's UI so it follows the mouse. + this.moveTo(x, y); + break; + case "click": + this.selectColor(); + break; + case "keydown": + this.handleKeyDown(e); + break; + case "DOMMouseScroll": + // Prevent scrolling. That's because we only took a screenshot of the viewport, so + // scrolling out of the viewport wouldn't draw the expected things. In the future + // we can take the screenshot again on scroll, but for now it doesn't seem + // important. + e.preventDefault(); + break; + case "FullZoomChange": + this.hide(); + this.show(); + break; + } + }, + + moveTo(x, y) { + let root = this.getElement("root"); + root.setAttribute("style", `top:${y}px;left:${x}px;`); + + // Move the label container to the top if the magnifier is close to the bottom edge. + if (y >= this.win.innerHeight - MAGNIFIER_HEIGHT) { + root.setAttribute("top", ""); + } else { + root.removeAttribute("top"); + } + + // Also offset the label container to the right or left if the magnifier is close to + // the edge. + root.removeAttribute("left"); + root.removeAttribute("right"); + if (x <= MAGNIFIER_WIDTH) { + root.setAttribute("right", ""); + } else if (x >= this.win.innerWidth - MAGNIFIER_WIDTH) { + root.setAttribute("left", ""); + } + }, + + /** + * Select the current color that's being previewed. Depending on the current options, + * selecting might mean copying to the clipboard and closing the + */ + selectColor() { + let onColorSelected = Promise.resolve(); + if (this.options.copyOnSelect) { + onColorSelected = this.copyColor(); + } + + this.emit("selected", toColorString(this.centerColor, this.format)); + onColorSelected.then(() => this.hide(), e => console.error(e)); + }, + + /** + * Handler for the keydown event. Either select the color or move the panel in a + * direction depending on the key pressed. + */ + handleKeyDown(e) { + // Bail out early if any unsupported modifier is used, so that we let + // keyboard shortcuts through. + if (e.metaKey || e.ctrlKey || e.altKey) { + return; + } + + if (e.keyCode === e.DOM_VK_RETURN) { + this.selectColor(); + e.preventDefault(); + return; + } + + if (e.keyCode === e.DOM_VK_ESCAPE) { + this.emit("canceled"); + this.hide(); + e.preventDefault(); + return; + } + + let offsetX = 0; + let offsetY = 0; + let modifier = 1; + + if (e.keyCode === e.DOM_VK_LEFT) { + offsetX = -1; + } else if (e.keyCode === e.DOM_VK_RIGHT) { + offsetX = 1; + } else if (e.keyCode === e.DOM_VK_UP) { + offsetY = -1; + } else if (e.keyCode === e.DOM_VK_DOWN) { + offsetY = 1; + } + + if (e.shiftKey) { + modifier = 10; + } + + offsetY *= modifier; + offsetX *= modifier; + + if (offsetX !== 0 || offsetY !== 0) { + this.magnifiedArea.x = cap(this.magnifiedArea.x + offsetX, + 0, this.win.innerWidth * this.pageZoom); + this.magnifiedArea.y = cap(this.magnifiedArea.y + offsetY, 0, + this.win.innerHeight * this.pageZoom); + + this.draw(); + + this.moveTo(this.magnifiedArea.x / this.pageZoom, + this.magnifiedArea.y / this.pageZoom); + + e.preventDefault(); + } + }, + + /** + * Copy the currently inspected color to the clipboard. + * @return {Promise} Resolves when the copy has been done (after a delay that is used to + * let users know that something was copied). + */ + copyColor() { + // Copy to the clipboard. + let color = toColorString(this.centerColor, this.format); + clipboardHelper.copyString(color); + + // Provide some feedback. + this.getElement("color-value").setTextContent( + "✓ " + l10n.GetStringFromName("colorValue.copied")); + + // Hide the tool after a delay. + clearTimeout(this._copyTimeout); + return new Promise(resolve => { + this._copyTimeout = setTimeout(resolve, CLOSE_DELAY); + }); + } +}; + +exports.EyeDropper = EyeDropper; + +/** + * Draw the visible portion of the window on a canvas and get the resulting ImageData. + * @param {Window} win + * @return {ImageData} The image data for the window. + */ +function getWindowAsImageData(win) { + let canvas = win.document.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); + let scale = getCurrentZoom(win); + let width = win.innerWidth; + let height = win.innerHeight; + canvas.width = width * scale; + canvas.height = height * scale; + canvas.mozOpaque = true; + + let ctx = canvas.getContext("2d"); + + ctx.scale(scale, scale); + ctx.drawWindow(win, win.scrollX, win.scrollY, width, height, "#fff"); + + return ctx.getImageData(0, 0, canvas.width, canvas.height); +} + +/** + * Get a formatted CSS color string from a color value. + * @param {array} rgb Rgb values of a color to format. + * @param {string} format Format of string. One of "hex", "rgb", "hsl", "name". + * @return {string} Formatted color value, e.g. "#FFF" or "hsl(20, 10%, 10%)". + */ +function toColorString(rgb, format) { + let [r, g, b] = rgb; + + switch (format) { + case "hex": + return hexString(rgb); + case "rgb": + return "rgb(" + r + ", " + g + ", " + b + ")"; + case "hsl": + let [h, s, l] = rgbToHsl(rgb); + return "hsl(" + h + ", " + s + "%, " + l + "%)"; + case "name": + let str; + try { + str = rgbToColorName(r, g, b); + } catch (e) { + str = hexString(rgb); + } + return str; + default: + return hexString(rgb); + } +} + +/** + * Produce a hex-formatted color string from rgb values. + * @param {array} rgb Rgb values of color to stringify. + * @return {string} Hex formatted string for color, e.g. "#FFEE00". + */ +function hexString([r, g, b]) { + let val = (1 << 24) + (r << 16) + (g << 8) + (b << 0); + return "#" + val.toString(16).substr(-6).toUpperCase(); +} + +function cap(value, min, max) { + return Math.max(min, Math.min(value, max)); +} diff --git a/devtools/server/actors/highlighters/geometry-editor.js b/devtools/server/actors/highlighters/geometry-editor.js new file mode 100644 index 000000000..35b33eec1 --- /dev/null +++ b/devtools/server/actors/highlighters/geometry-editor.js @@ -0,0 +1,704 @@ +/* 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 { extend } = require("sdk/core/heritage"); +const { AutoRefreshHighlighter } = require("./auto-refresh"); +const { CanvasFrameAnonymousContentHelper, getCSSStyleRules, getComputedStyle, + createSVGNode, createNode } = require("./utils/markup"); +const { setIgnoreLayoutChanges, getAdjustedQuads } = require("devtools/shared/layout/utils"); + +const GEOMETRY_LABEL_SIZE = 6; + +// List of all DOM Events subscribed directly to the document from the +// Geometry Editor highlighter +const DOM_EVENTS = ["mousemove", "mouseup", "pagehide"]; + +const _dragging = Symbol("geometry/dragging"); + +/** + * Element geometry properties helper that gives names of position and size + * properties. + */ +var GeoProp = { + SIDES: ["top", "right", "bottom", "left"], + SIZES: ["width", "height"], + + allProps: function () { + return [...this.SIDES, ...this.SIZES]; + }, + + isSide: function (name) { + return this.SIDES.indexOf(name) !== -1; + }, + + isSize: function (name) { + return this.SIZES.indexOf(name) !== -1; + }, + + containsSide: function (names) { + return names.some(name => this.SIDES.indexOf(name) !== -1); + }, + + containsSize: function (names) { + return names.some(name => this.SIZES.indexOf(name) !== -1); + }, + + isHorizontal: function (name) { + return name === "left" || name === "right" || name === "width"; + }, + + isInverted: function (name) { + return name === "right" || name === "bottom"; + }, + + mainAxisStart: function (name) { + return this.isHorizontal(name) ? "left" : "top"; + }, + + crossAxisStart: function (name) { + return this.isHorizontal(name) ? "top" : "left"; + }, + + mainAxisSize: function (name) { + return this.isHorizontal(name) ? "width" : "height"; + }, + + crossAxisSize: function (name) { + return this.isHorizontal(name) ? "height" : "width"; + }, + + axis: function (name) { + return this.isHorizontal(name) ? "x" : "y"; + }, + + crossAxis: function (name) { + return this.isHorizontal(name) ? "y" : "x"; + } +}; + +/** + * Get the provided node's offsetParent dimensions. + * Returns an object with the {parent, dimension} properties. + * Note that the returned parent will be null if the offsetParent is the + * default, non-positioned, body or html node. + * + * node.offsetParent returns the nearest positioned ancestor but if it is + * non-positioned itself, we just return null to let consumers know the node is + * actually positioned relative to the viewport. + * + * @return {Object} + */ +function getOffsetParent(node) { + let win = node.ownerDocument.defaultView; + + let offsetParent = node.offsetParent; + if (offsetParent && + getComputedStyle(offsetParent).position === "static") { + offsetParent = null; + } + + let width, height; + if (!offsetParent) { + height = win.innerHeight; + width = win.innerWidth; + } else { + height = offsetParent.offsetHeight; + width = offsetParent.offsetWidth; + } + + return { + element: offsetParent, + dimension: {width, height} + }; +} + +/** + * Get the list of geometry properties that are actually set on the provided + * node. + * + * @param {nsIDOMNode} node The node to analyze. + * @return {Map} A map indexed by property name and where the value is an + * object having the cssRule property. + */ +function getDefinedGeometryProperties(node) { + let props = new Map(); + if (!node) { + return props; + } + + // Get the list of css rules applying to the current node. + let cssRules = getCSSStyleRules(node); + for (let i = 0; i < cssRules.Count(); i++) { + let rule = cssRules.GetElementAt(i); + for (let name of GeoProp.allProps()) { + let value = rule.style.getPropertyValue(name); + if (value && value !== "auto") { + // getCSSStyleRules returns rules ordered from least to most specific + // so just override any previous properties we have set. + props.set(name, { + cssRule: rule + }); + } + } + } + + // Go through the inline styles last, only if the node supports inline style + // (e.g. pseudo elements don't have a style property) + if (node.style) { + for (let name of GeoProp.allProps()) { + let value = node.style.getPropertyValue(name); + if (value && value !== "auto") { + props.set(name, { + // There's no cssRule to store here, so store the node instead since + // node.style exists. + cssRule: node + }); + } + } + } + + // Post-process the list for invalid properties. This is done after the fact + // because of cases like relative positioning with both top and bottom where + // only top will actually be used, but both exists in css rules and computed + // styles. + let { position } = getComputedStyle(node); + for (let [name] of props) { + // Top/left/bottom/right on static positioned elements have no effect. + if (position === "static" && GeoProp.SIDES.indexOf(name) !== -1) { + props.delete(name); + } + + // Bottom/right on relative positioned elements are only used if top/left + // are not defined. + let hasRightAndLeft = name === "right" && props.has("left"); + let hasBottomAndTop = name === "bottom" && props.has("top"); + if (position === "relative" && (hasRightAndLeft || hasBottomAndTop)) { + props.delete(name); + } + } + + return props; +} +exports.getDefinedGeometryProperties = getDefinedGeometryProperties; + +/** + * The GeometryEditor highlights an elements's top, left, bottom, right, width + * and height dimensions, when they are set. + * + * To determine if an element has a set size and position, the highlighter lists + * the CSS rules that apply to the element and checks for the top, left, bottom, + * right, width and height properties. + * The highlighter won't be shown if the element doesn't have any of these + * properties set, but will be shown when at least 1 property is defined. + * + * The highlighter displays lines and labels for each of the defined properties + * in and around the element (relative to the offset parent when one exists). + * The highlighter also highlights the element itself and its offset parent if + * there is one. + * + * Note that the class name contains the word Editor because the aim is for the + * handles to be draggable in content to make the geometry editable. + */ +function GeometryEditorHighlighter(highlighterEnv) { + AutoRefreshHighlighter.call(this, highlighterEnv); + + // The list of element geometry properties that can be set. + this.definedProperties = new Map(); + + this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv, + this._buildMarkup.bind(this)); + + let { pageListenerTarget } = this.highlighterEnv; + + // Register the geometry editor instance to all events we're interested in. + DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this)); + + // Register the mousedown event for each Geometry Editor's handler. + // Those events are automatically removed when the markup is destroyed. + let onMouseDown = this.handleEvent.bind(this); + + for (let side of GeoProp.SIDES) { + this.getElement("handler-" + side) + .addEventListener("mousedown", onMouseDown); + } +} + +GeometryEditorHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, { + typeName: "GeometryEditorHighlighter", + + ID_CLASS_PREFIX: "geometry-editor-", + + _buildMarkup: function () { + let container = createNode(this.win, { + attributes: {"class": "highlighter-container"} + }); + + let root = createNode(this.win, { + parent: container, + attributes: { + "id": "root", + "class": "root", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let svg = createSVGNode(this.win, { + nodeType: "svg", + parent: root, + attributes: { + "id": "elements", + "width": "100%", + "height": "100%" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Offset parent node highlighter. + createSVGNode(this.win, { + nodeType: "polygon", + parent: svg, + attributes: { + "class": "offset-parent", + "id": "offset-parent", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Current node highlighter (margin box). + createSVGNode(this.win, { + nodeType: "polygon", + parent: svg, + attributes: { + "class": "current-node", + "id": "current-node", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Build the 4 side arrows, handlers and labels. + for (let name of GeoProp.SIDES) { + createSVGNode(this.win, { + nodeType: "line", + parent: svg, + attributes: { + "class": "arrow " + name, + "id": "arrow-" + name, + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + createSVGNode(this.win, { + nodeType: "circle", + parent: svg, + attributes: { + "class": "handler-" + name, + "id": "handler-" + name, + "r": "4", + "data-side": name, + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Labels are positioned by using a translated <g>. This group contains + // a path and text that are themselves positioned using another translated + // <g>. This is so that the label arrow points at the 0,0 coordinates of + // parent <g>. + let labelG = createSVGNode(this.win, { + nodeType: "g", + parent: svg, + attributes: { + "id": "label-" + name, + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let subG = createSVGNode(this.win, { + nodeType: "g", + parent: labelG, + attributes: { + "transform": GeoProp.isHorizontal(name) + ? "translate(-30 -30)" + : "translate(5 -10)" + } + }); + + createSVGNode(this.win, { + nodeType: "path", + parent: subG, + attributes: { + "class": "label-bubble", + "d": GeoProp.isHorizontal(name) + ? "M0 0 L60 0 L60 20 L35 20 L30 25 L25 20 L0 20z" + : "M5 0 L65 0 L65 20 L5 20 L5 15 L0 10 L5 5z" + }, + prefix: this.ID_CLASS_PREFIX + }); + + createSVGNode(this.win, { + nodeType: "text", + parent: subG, + attributes: { + "class": "label-text", + "id": "label-text-" + name, + "x": GeoProp.isHorizontal(name) ? "30" : "35", + "y": "10" + }, + prefix: this.ID_CLASS_PREFIX + }); + } + + return container; + }, + + destroy: function () { + // Avoiding exceptions if `destroy` is called multiple times; and / or the + // highlighter environment was already destroyed. + if (!this.highlighterEnv) { + return; + } + + let { pageListenerTarget } = this.highlighterEnv; + + DOM_EVENTS.forEach(type => + pageListenerTarget.removeEventListener(type, this)); + + AutoRefreshHighlighter.prototype.destroy.call(this); + + this.markup.destroy(); + this.definedProperties.clear(); + this.definedProperties = null; + this.offsetParent = null; + }, + + handleEvent: function (event, id) { + // No event handling if the highlighter is hidden + if (this.getElement("root").hasAttribute("hidden")) { + return; + } + + const { type, pageX, pageY } = event; + + switch (type) { + case "pagehide": + this.destroy(); + break; + case "mousedown": + // The mousedown event is intended only for the handler + if (!id) { + return; + } + + let handlerSide = this.markup.getElement(id).getAttribute("data-side"); + + if (handlerSide) { + let side = handlerSide; + let sideProp = this.definedProperties.get(side); + + if (!sideProp) { + return; + } + + let value = sideProp.cssRule.style.getPropertyValue(side); + let computedValue = this.computedStyle.getPropertyValue(side); + + let [unit] = value.match(/[^\d]+$/) || [""]; + + value = parseFloat(value); + + let ratio = (value / parseFloat(computedValue)) || 1; + let dir = GeoProp.isInverted(side) ? -1 : 1; + + // Store all the initial values needed for drag & drop + this[_dragging] = { + side, + value, + unit, + x: pageX, + y: pageY, + inc: ratio * dir + }; + + this.getElement("handler-" + side).classList.add("dragging"); + } + + this.getElement("root").setAttribute("dragging", "true"); + break; + case "mouseup": + // If we're dragging, drop it. + if (this[_dragging]) { + let { side } = this[_dragging]; + this.getElement("root").removeAttribute("dragging"); + this.getElement("handler-" + side).classList.remove("dragging"); + this[_dragging] = null; + } + break; + case "mousemove": + if (!this[_dragging]) { + return; + } + + let { side, x, y, value, unit, inc } = this[_dragging]; + let sideProps = this.definedProperties.get(side); + + if (!sideProps) { + return; + } + + let delta = (GeoProp.isHorizontal(side) ? pageX - x : pageY - y) * inc; + + // The inline style has usually the priority over any other CSS rule + // set in stylesheets. However, if a rule has `!important` keyword, + // it will override the inline style too. To ensure Geometry Editor + // will always update the element, we have to add `!important` as + // well. + this.currentNode.style.setProperty( + side, (value + delta) + unit, "important"); + + break; + } + }, + + getElement: function (id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + _show: function () { + this.computedStyle = getComputedStyle(this.currentNode); + let pos = this.computedStyle.position; + // XXX: sticky positioning is ignored for now. To be implemented next. + if (pos === "sticky") { + this.hide(); + return false; + } + + let hasUpdated = this._update(); + if (!hasUpdated) { + this.hide(); + return false; + } + + this.getElement("root").removeAttribute("hidden"); + + return true; + }, + + _update: function () { + // At each update, the position or/and size may have changed, so get the + // list of defined properties, and re-position the arrows and highlighters. + this.definedProperties = getDefinedGeometryProperties(this.currentNode); + + if (!this.definedProperties.size) { + console.warn("The element does not have editable geometry properties"); + return false; + } + + setIgnoreLayoutChanges(true); + + // Update the highlighters and arrows. + this.updateOffsetParent(); + this.updateCurrentNode(); + this.updateArrows(); + + // Avoid zooming the arrows when content is zoomed. + let node = this.currentNode; + this.markup.scaleRootElement(node, this.ID_CLASS_PREFIX + "root"); + + setIgnoreLayoutChanges(false, node.ownerDocument.documentElement); + return true; + }, + + /** + * Update the offset parent rectangle. + * There are 3 different cases covered here: + * - the node is absolutely/fixed positioned, and an offsetParent is defined + * (i.e. it's not just positioned in the viewport): the offsetParent node + * is highlighted (i.e. the rectangle is shown), + * - the node is relatively positioned: the rectangle is shown where the node + * would originally have been (because that's where the relative positioning + * is calculated from), + * - the node has no offset parent at all: the offsetParent rectangle is + * hidden. + */ + updateOffsetParent: function () { + // Get the offsetParent, if any. + this.offsetParent = getOffsetParent(this.currentNode); + // And the offsetParent quads. + this.parentQuads = getAdjustedQuads( + this.win, this.offsetParent.element, "padding"); + + let el = this.getElement("offset-parent"); + + let isPositioned = this.computedStyle.position === "absolute" || + this.computedStyle.position === "fixed"; + let isRelative = this.computedStyle.position === "relative"; + let isHighlighted = false; + + if (this.offsetParent.element && isPositioned) { + let {p1, p2, p3, p4} = this.parentQuads[0]; + let points = p1.x + "," + p1.y + " " + + p2.x + "," + p2.y + " " + + p3.x + "," + p3.y + " " + + p4.x + "," + p4.y; + el.setAttribute("points", points); + isHighlighted = true; + } else if (isRelative) { + let xDelta = parseFloat(this.computedStyle.left); + let yDelta = parseFloat(this.computedStyle.top); + if (xDelta || yDelta) { + let {p1, p2, p3, p4} = this.currentQuads.margin[0]; + let points = (p1.x - xDelta) + "," + (p1.y - yDelta) + " " + + (p2.x - xDelta) + "," + (p2.y - yDelta) + " " + + (p3.x - xDelta) + "," + (p3.y - yDelta) + " " + + (p4.x - xDelta) + "," + (p4.y - yDelta); + el.setAttribute("points", points); + isHighlighted = true; + } + } + + if (isHighlighted) { + el.removeAttribute("hidden"); + } else { + el.setAttribute("hidden", "true"); + } + }, + + updateCurrentNode: function () { + let box = this.getElement("current-node"); + let {p1, p2, p3, p4} = this.currentQuads.margin[0]; + let attr = p1.x + "," + p1.y + " " + + p2.x + "," + p2.y + " " + + p3.x + "," + p3.y + " " + + p4.x + "," + p4.y; + box.setAttribute("points", attr); + box.removeAttribute("hidden"); + }, + + _hide: function () { + setIgnoreLayoutChanges(true); + + this.getElement("root").setAttribute("hidden", "true"); + this.getElement("current-node").setAttribute("hidden", "true"); + this.getElement("offset-parent").setAttribute("hidden", "true"); + this.hideArrows(); + + this.definedProperties.clear(); + + setIgnoreLayoutChanges(false, + this.currentNode.ownerDocument.documentElement); + }, + + hideArrows: function () { + for (let side of GeoProp.SIDES) { + this.getElement("arrow-" + side).setAttribute("hidden", "true"); + this.getElement("label-" + side).setAttribute("hidden", "true"); + this.getElement("handler-" + side).setAttribute("hidden", "true"); + } + }, + + updateArrows: function () { + this.hideArrows(); + + // Position arrows always end at the node's margin box. + let marginBox = this.currentQuads.margin[0].bounds; + + // Position the side arrows which need to be visible. + // Arrows always start at the offsetParent edge, and end at the middle + // position of the node's margin edge. + // Note that for relative positioning, the offsetParent is considered to be + // the node itself, where it would have been originally. + // +------------------+----------------+ + // | offsetparent | top | + // | or viewport | | + // | +--------+--------+ | + // | | node | | + // +---------+ +-------+ + // | left | | right | + // | +--------+--------+ | + // | | bottom | + // +------------------+----------------+ + let getSideArrowStartPos = side => { + // In case an offsetParent exists and is highlighted. + if (this.parentQuads && this.parentQuads.length) { + return this.parentQuads[0].bounds[side]; + } + + // In case of relative positioning. + if (this.computedStyle.position === "relative") { + if (GeoProp.isInverted(side)) { + return marginBox[side] + parseFloat(this.computedStyle[side]); + } + return marginBox[side] - parseFloat(this.computedStyle[side]); + } + + // In case the element is positioned in the viewport. + if (GeoProp.isInverted(side)) { + return this.offsetParent.dimension[GeoProp.mainAxisSize(side)]; + } + return -1 * this.currentNode.ownerDocument.defaultView["scroll" + + GeoProp.axis(side).toUpperCase()]; + }; + + for (let side of GeoProp.SIDES) { + let sideProp = this.definedProperties.get(side); + if (!sideProp) { + continue; + } + + let mainAxisStartPos = getSideArrowStartPos(side); + let mainAxisEndPos = marginBox[side]; + let crossAxisPos = marginBox[GeoProp.crossAxisStart(side)] + + marginBox[GeoProp.crossAxisSize(side)] / 2; + + this.updateArrow(side, mainAxisStartPos, mainAxisEndPos, crossAxisPos, + sideProp.cssRule.style.getPropertyValue(side)); + } + }, + + updateArrow: function (side, mainStart, mainEnd, crossPos, labelValue) { + let arrowEl = this.getElement("arrow-" + side); + let labelEl = this.getElement("label-" + side); + let labelTextEl = this.getElement("label-text-" + side); + let handlerEl = this.getElement("handler-" + side); + + // Position the arrow <line>. + arrowEl.setAttribute(GeoProp.axis(side) + "1", mainStart); + arrowEl.setAttribute(GeoProp.crossAxis(side) + "1", crossPos); + arrowEl.setAttribute(GeoProp.axis(side) + "2", mainEnd); + arrowEl.setAttribute(GeoProp.crossAxis(side) + "2", crossPos); + arrowEl.removeAttribute("hidden"); + + handlerEl.setAttribute("c" + GeoProp.axis(side), mainEnd); + handlerEl.setAttribute("c" + GeoProp.crossAxis(side), crossPos); + handlerEl.removeAttribute("hidden"); + + // Position the label <text> in the middle of the arrow (making sure it's + // not hidden below the fold). + let capitalize = str => str[0].toUpperCase() + str.substring(1); + let winMain = this.win["inner" + capitalize(GeoProp.mainAxisSize(side))]; + let labelMain = mainStart + (mainEnd - mainStart) / 2; + if ((mainStart > 0 && mainStart < winMain) || + (mainEnd > 0 && mainEnd < winMain)) { + if (labelMain < GEOMETRY_LABEL_SIZE) { + labelMain = GEOMETRY_LABEL_SIZE; + } else if (labelMain > winMain - GEOMETRY_LABEL_SIZE) { + labelMain = winMain - GEOMETRY_LABEL_SIZE; + } + } + let labelCross = crossPos; + labelEl.setAttribute("transform", GeoProp.isHorizontal(side) + ? "translate(" + labelMain + " " + labelCross + ")" + : "translate(" + labelCross + " " + labelMain + ")"); + labelEl.removeAttribute("hidden"); + labelTextEl.setTextContent(labelValue); + } +}); +exports.GeometryEditorHighlighter = GeometryEditorHighlighter; diff --git a/devtools/server/actors/highlighters/measuring-tool.js b/devtools/server/actors/highlighters/measuring-tool.js new file mode 100644 index 000000000..e1e1de94f --- /dev/null +++ b/devtools/server/actors/highlighters/measuring-tool.js @@ -0,0 +1,563 @@ +/* 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 events = require("sdk/event/core"); +const { getCurrentZoom, + setIgnoreLayoutChanges } = require("devtools/shared/layout/utils"); +const { + CanvasFrameAnonymousContentHelper, + createSVGNode, createNode } = require("./utils/markup"); + +// Hard coded value about the size of measuring tool label, in order to +// position and flip it when is needed. +const LABEL_SIZE_MARGIN = 8; +const LABEL_SIZE_WIDTH = 80; +const LABEL_SIZE_HEIGHT = 52; +const LABEL_POS_MARGIN = 4; +const LABEL_POS_WIDTH = 40; +const LABEL_POS_HEIGHT = 34; + +const SIDES = ["top", "right", "bottom", "left"]; + +/** + * The MeasuringToolHighlighter is used to measure distances in a content page. + * It allows users to click and drag with their mouse to draw an area whose + * dimensions will be displayed in a tooltip next to it. + * This allows users to measure distances between elements on a page. + */ +function MeasuringToolHighlighter(highlighterEnv) { + this.env = highlighterEnv; + this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv, + this._buildMarkup.bind(this)); + + this.coords = { + x: 0, + y: 0 + }; + + let { pageListenerTarget } = highlighterEnv; + + pageListenerTarget.addEventListener("mousedown", this); + pageListenerTarget.addEventListener("mousemove", this); + pageListenerTarget.addEventListener("mouseleave", this); + pageListenerTarget.addEventListener("scroll", this); + pageListenerTarget.addEventListener("pagehide", this); +} + +MeasuringToolHighlighter.prototype = { + typeName: "MeasuringToolHighlighter", + + ID_CLASS_PREFIX: "measuring-tool-highlighter-", + + _buildMarkup() { + let prefix = this.ID_CLASS_PREFIX; + let { window } = this.env; + + let container = createNode(window, { + attributes: {"class": "highlighter-container"} + }); + + let root = createNode(window, { + parent: container, + attributes: { + "id": "root", + "class": "root", + }, + prefix + }); + + let svg = createSVGNode(window, { + nodeType: "svg", + parent: root, + attributes: { + id: "elements", + "class": "elements", + width: "100%", + height: "100%", + hidden: "true" + }, + prefix + }); + + createNode(window, { + nodeType: "label", + attributes: { + id: "label-size", + "class": "label-size", + "hidden": "true" + }, + parent: root, + prefix + }); + + createNode(window, { + nodeType: "label", + attributes: { + id: "label-position", + "class": "label-position", + "hidden": "true" + }, + parent: root, + prefix + }); + + // Creating a <g> element in order to group all the paths below, that + // together represent the measuring tool; so that would be easier move them + // around + let g = createSVGNode(window, { + nodeType: "g", + attributes: { + id: "tool", + }, + parent: svg, + prefix + }); + + createSVGNode(window, { + nodeType: "path", + attributes: { + id: "box-path" + }, + parent: g, + prefix + }); + + createSVGNode(window, { + nodeType: "path", + attributes: { + id: "diagonal-path" + }, + parent: g, + prefix + }); + + for (let side of SIDES) { + createSVGNode(window, { + nodeType: "line", + parent: svg, + attributes: { + "class": `guide-${side}`, + id: `guide-${side}`, + hidden: "true" + }, + prefix + }); + } + + return container; + }, + + _update() { + let { window } = this.env; + + setIgnoreLayoutChanges(true); + + let zoom = getCurrentZoom(window); + + let { documentElement } = window.document; + + let width = Math.max(documentElement.clientWidth, + documentElement.scrollWidth, + documentElement.offsetWidth); + + let height = Math.max(documentElement.clientHeight, + documentElement.scrollHeight, + documentElement.offsetHeight); + + let { body } = window.document; + + // get the size of the content document despite the compatMode + if (body) { + width = Math.max(width, body.scrollWidth, body.offsetWidth); + height = Math.max(height, body.scrollHeight, body.offsetHeight); + } + + let { coords } = this; + + let isZoomChanged = zoom !== coords.zoom; + + if (isZoomChanged) { + coords.zoom = zoom; + this.updateLabel(); + } + + let isDocumentSizeChanged = width !== coords.documentWidth || + height !== coords.documentHeight; + + if (isDocumentSizeChanged) { + coords.documentWidth = width; + coords.documentHeight = height; + } + + // If either the document's size or the zoom is changed since the last + // repaint, we update the tool's size as well. + if (isZoomChanged || isDocumentSizeChanged) { + this.updateViewport(); + } + + setIgnoreLayoutChanges(false, documentElement); + + this._rafID = window.requestAnimationFrame(() => this._update()); + }, + + _cancelUpdate() { + if (this._rafID) { + this.env.window.cancelAnimationFrame(this._rafID); + this._rafID = 0; + } + }, + + destroy() { + this.hide(); + + this._cancelUpdate(); + + let { pageListenerTarget } = this.env; + + pageListenerTarget.removeEventListener("mousedown", this); + pageListenerTarget.removeEventListener("mousemove", this); + pageListenerTarget.removeEventListener("mouseup", this); + pageListenerTarget.removeEventListener("scroll", this); + pageListenerTarget.removeEventListener("pagehide", this); + pageListenerTarget.removeEventListener("mouseleave", this); + + this.markup.destroy(); + + events.emit(this, "destroy"); + }, + + show() { + setIgnoreLayoutChanges(true); + + this.getElement("elements").removeAttribute("hidden"); + + this._update(); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + hide() { + setIgnoreLayoutChanges(true); + + this.hideLabel("size"); + this.hideLabel("position"); + + this.getElement("elements").setAttribute("hidden", "true"); + + this._cancelUpdate(); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + setSize(w, h) { + this.setCoords(undefined, undefined, w, h); + }, + + setCoords(x, y, w, h) { + let { coords } = this; + + if (typeof x !== "undefined") { + coords.x = x; + } + + if (typeof y !== "undefined") { + coords.y = y; + } + + if (typeof w !== "undefined") { + coords.w = w; + } + + if (typeof h !== "undefined") { + coords.h = h; + } + + setIgnoreLayoutChanges(true); + + if (this._isDragging) { + this.updatePaths(); + } + + this.updateLabel(); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + updatePaths() { + let { x, y, w, h } = this.coords; + let dir = `M0 0 L${w} 0 L${w} ${h} L0 ${h}z`; + + // Adding correction to the line path, otherwise some pixels are drawn + // outside the main rectangle area. + let x1 = w > 0 ? 0.5 : 0; + let y1 = w < 0 && h < 0 ? -0.5 : 0; + let w1 = w + (h < 0 && w < 0 ? 0.5 : 0); + let h1 = h + (h > 0 && w > 0 ? -0.5 : 0); + + let linedir = `M${x1} ${y1} L${w1} ${h1}`; + + this.getElement("box-path").setAttribute("d", dir); + this.getElement("diagonal-path").setAttribute("d", linedir); + this.getElement("tool").setAttribute("transform", `translate(${x},${y})`); + }, + + updateLabel(type) { + type = type || this._isDragging ? "size" : "position"; + + let isSizeLabel = type === "size"; + + let label = this.getElement(`label-${type}`); + + let origin = "top left"; + + let { innerWidth, innerHeight, scrollX, scrollY } = this.env.window; + let { x, y, w, h, zoom } = this.coords; + let scale = 1 / zoom; + + w = w || 0; + h = h || 0; + x = (x || 0) + w; + y = (y || 0) + h; + + let labelMargin, labelHeight, labelWidth; + + if (isSizeLabel) { + labelMargin = LABEL_SIZE_MARGIN; + labelWidth = LABEL_SIZE_WIDTH; + labelHeight = LABEL_SIZE_HEIGHT; + + let d = Math.hypot(w, h).toFixed(2); + + label.setTextContent(`W: ${Math.abs(w)} px + H: ${Math.abs(h)} px + ↘: ${d}px`); + } else { + labelMargin = LABEL_POS_MARGIN; + labelWidth = LABEL_POS_WIDTH; + labelHeight = LABEL_POS_HEIGHT; + + label.setTextContent(`${x} + ${y}`); + } + + // Size used to position properly the label + let labelBoxWidth = (labelWidth + labelMargin) * scale; + let labelBoxHeight = (labelHeight + labelMargin) * scale; + + let isGoingLeft = w < scrollX; + let isSizeGoingLeft = isSizeLabel && isGoingLeft; + let isExceedingLeftMargin = x - labelBoxWidth < scrollX; + let isExceedingRightMargin = x + labelBoxWidth > innerWidth + scrollX; + let isExceedingTopMargin = y - labelBoxHeight < scrollY; + let isExceedingBottomMargin = y + labelBoxHeight > innerHeight + scrollY; + + if ((isSizeGoingLeft && !isExceedingLeftMargin) || isExceedingRightMargin) { + x -= labelBoxWidth; + origin = "top right"; + } else { + x += labelMargin * scale; + } + + if (isSizeLabel) { + y += isExceedingTopMargin ? labelMargin * scale : -labelBoxHeight; + } else { + y += isExceedingBottomMargin ? -labelBoxHeight : labelMargin * scale; + } + + label.setAttribute("style", ` + width: ${labelWidth}px; + height: ${labelHeight}px; + transform-origin: ${origin}; + transform: translate(${x}px,${y}px) scale(${scale}) + `); + + if (!isSizeLabel) { + let labelSize = this.getElement("label-size"); + let style = labelSize.getAttribute("style"); + + if (style) { + labelSize.setAttribute("style", + style.replace(/scale[^)]+\)/, `scale(${scale})`)); + } + } + }, + + updateViewport() { + let { scrollX, scrollY, devicePixelRatio } = this.env.window; + let { documentWidth, documentHeight, zoom } = this.coords; + + // Because `devicePixelRatio` is affected by zoom (see bug 809788), + // in order to get the "real" device pixel ratio, we need divide by `zoom` + let pixelRatio = devicePixelRatio / zoom; + + // The "real" device pixel ratio is used to calculate the max stroke + // width we can actually assign: on retina, for instance, it would be 0.5, + // where on non high dpi monitor would be 1. + let minWidth = 1 / pixelRatio; + let strokeWidth = Math.min(minWidth, minWidth / zoom); + + this.getElement("root").setAttribute("style", + `stroke-width:${strokeWidth}; + width:${documentWidth}px; + height:${documentHeight}px; + transform: translate(${-scrollX}px,${-scrollY}px)`); + }, + + updateGuides() { + let { x, y, w, h } = this.coords; + + let guide = this.getElement("guide-top"); + + guide.setAttribute("x1", "0"); + guide.setAttribute("y1", y); + guide.setAttribute("x2", "100%"); + guide.setAttribute("y2", y); + + guide = this.getElement("guide-right"); + + guide.setAttribute("x1", x + w); + guide.setAttribute("y1", 0); + guide.setAttribute("x2", x + w); + guide.setAttribute("y2", "100%"); + + guide = this.getElement("guide-bottom"); + + guide.setAttribute("x1", "0"); + guide.setAttribute("y1", y + h); + guide.setAttribute("x2", "100%"); + guide.setAttribute("y2", y + h); + + guide = this.getElement("guide-left"); + + guide.setAttribute("x1", x); + guide.setAttribute("y1", 0); + guide.setAttribute("x2", x); + guide.setAttribute("y2", "100%"); + }, + + showLabel(type) { + setIgnoreLayoutChanges(true); + + this.getElement(`label-${type}`).removeAttribute("hidden"); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + hideLabel(type) { + setIgnoreLayoutChanges(true); + + this.getElement(`label-${type}`).setAttribute("hidden", "true"); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + showGuides() { + let prefix = this.ID_CLASS_PREFIX + "guide-"; + + for (let side of SIDES) { + this.markup.removeAttributeForElement(`${prefix + side}`, "hidden"); + } + }, + + hideGuides() { + let prefix = this.ID_CLASS_PREFIX + "guide-"; + + for (let side of SIDES) { + this.markup.setAttributeForElement(`${prefix + side}`, "hidden", "true"); + } + }, + + handleEvent(event) { + let scrollX, scrollY, innerWidth, innerHeight; + let x, y; + + let { pageListenerTarget } = this.env; + + switch (event.type) { + case "mousedown": + if (event.button) { + return; + } + + this._isDragging = true; + + let { window } = this.env; + + ({ scrollX, scrollY } = window); + x = event.clientX + scrollX; + y = event.clientY + scrollY; + + pageListenerTarget.addEventListener("mouseup", this); + + setIgnoreLayoutChanges(true); + + this.getElement("tool").setAttribute("class", "dragging"); + + this.hideLabel("size"); + this.hideLabel("position"); + + this.hideGuides(); + this.setCoords(x, y, 0, 0); + + setIgnoreLayoutChanges(false, window.document.documentElement); + + break; + case "mouseup": + this._isDragging = false; + + pageListenerTarget.removeEventListener("mouseup", this); + + setIgnoreLayoutChanges(true); + + this.getElement("tool").removeAttribute("class", ""); + + // Shows the guides only if an actual area is selected + if (this.coords.w !== 0 && this.coords.h !== 0) { + this.updateGuides(); + this.showGuides(); + } + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + + break; + case "mousemove": + ({ scrollX, scrollY, innerWidth, innerHeight } = this.env.window); + x = event.clientX + scrollX; + y = event.clientY + scrollY; + + let { coords } = this; + + x = Math.min(innerWidth + scrollX - 1, Math.max(0 + scrollX, x)); + y = Math.min(innerHeight + scrollY, Math.max(1 + scrollY, y)); + + this.setSize(x - coords.x, y - coords.y); + + let type = this._isDragging ? "size" : "position"; + + this.showLabel(type); + break; + case "mouseleave": + if (!this._isDragging) { + this.hideLabel("position"); + } + break; + case "scroll": + setIgnoreLayoutChanges(true); + this.updateViewport(); + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + + break; + case "pagehide": + this.destroy(); + break; + } + } +}; +exports.MeasuringToolHighlighter = MeasuringToolHighlighter; diff --git a/devtools/server/actors/highlighters/moz.build b/devtools/server/actors/highlighters/moz.build new file mode 100644 index 000000000..317d0832c --- /dev/null +++ b/devtools/server/actors/highlighters/moz.build @@ -0,0 +1,23 @@ +# -*- 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/. + +DIRS += [ + 'utils', +] + +DevToolsModules( + 'auto-refresh.js', + 'box-model.js', + 'css-grid.js', + 'css-transform.js', + 'eye-dropper.js', + 'geometry-editor.js', + 'measuring-tool.js', + 'rect.js', + 'rulers.js', + 'selector.js', + 'simple-outline.js' +) diff --git a/devtools/server/actors/highlighters/rect.js b/devtools/server/actors/highlighters/rect.js new file mode 100644 index 000000000..69ff09880 --- /dev/null +++ b/devtools/server/actors/highlighters/rect.js @@ -0,0 +1,102 @@ +/* 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 { CanvasFrameAnonymousContentHelper } = require("./utils/markup"); +const { getAdjustedQuads } = require("devtools/shared/layout/utils"); +/** + * The RectHighlighter is a class that draws a rectangle highlighter at specific + * coordinates. + * It does *not* highlight DOM nodes, but rects. + * It also does *not* update dynamically, it only highlights a rect and remains + * there as long as it is shown. + */ +function RectHighlighter(highlighterEnv) { + this.win = highlighterEnv.window; + this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv, + this._buildMarkup.bind(this)); +} + +RectHighlighter.prototype = { + typeName: "RectHighlighter", + + _buildMarkup: function () { + let doc = this.win.document; + + let container = doc.createElement("div"); + container.className = "highlighter-container"; + container.innerHTML = "<div id=\"highlighted-rect\" " + + "class=\"highlighted-rect\" hidden=\"true\">"; + + return container; + }, + + destroy: function () { + this.win = null; + this.markup.destroy(); + }, + + getElement: function (id) { + return this.markup.getElement(id); + }, + + _hasValidOptions: function (options) { + let isValidNb = n => typeof n === "number" && n >= 0 && isFinite(n); + return options && options.rect && + isValidNb(options.rect.x) && + isValidNb(options.rect.y) && + options.rect.width && isValidNb(options.rect.width) && + options.rect.height && isValidNb(options.rect.height); + }, + + /** + * @param {DOMNode} node The highlighter rect is relatively positioned to the + * viewport this node is in. Using the provided node, the highligther will get + * the parent documentElement and use it as context to position the + * highlighter correctly. + * @param {Object} options Accepts the following options: + * - rect: mandatory object that should have the x, y, width, height + * properties + * - fill: optional fill color for the rect + */ + show: function (node, options) { + if (!this._hasValidOptions(options) || !node || !node.ownerDocument) { + this.hide(); + return false; + } + + let contextNode = node.ownerDocument.documentElement; + + // Caculate the absolute rect based on the context node's adjusted quads. + let quads = getAdjustedQuads(this.win, contextNode); + if (!quads.length) { + this.hide(); + return false; + } + + let {bounds} = quads[0]; + let x = "left:" + (bounds.x + options.rect.x) + "px;"; + let y = "top:" + (bounds.y + options.rect.y) + "px;"; + let width = "width:" + options.rect.width + "px;"; + let height = "height:" + options.rect.height + "px;"; + + let style = x + y + width + height; + if (options.fill) { + style += "background:" + options.fill + ";"; + } + + // Set the coordinates of the highlighter and show it + let rect = this.getElement("highlighted-rect"); + rect.setAttribute("style", style); + rect.removeAttribute("hidden"); + + return true; + }, + + hide: function () { + this.getElement("highlighted-rect").setAttribute("hidden", "true"); + } +}; +exports.RectHighlighter = RectHighlighter; diff --git a/devtools/server/actors/highlighters/rulers.js b/devtools/server/actors/highlighters/rulers.js new file mode 100644 index 000000000..01e082e67 --- /dev/null +++ b/devtools/server/actors/highlighters/rulers.js @@ -0,0 +1,294 @@ +/* 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 events = require("sdk/event/core"); +const { getCurrentZoom, + setIgnoreLayoutChanges } = require("devtools/shared/layout/utils"); +const { + CanvasFrameAnonymousContentHelper, + createSVGNode, createNode } = require("./utils/markup"); + +// Maximum size, in pixel, for the horizontal ruler and vertical ruler +// used by RulersHighlighter +const RULERS_MAX_X_AXIS = 10000; +const RULERS_MAX_Y_AXIS = 15000; +// Number of steps after we add a graduation, marker and text in +// RulersHighliter; currently the unit is in pixel. +const RULERS_GRADUATION_STEP = 5; +const RULERS_MARKER_STEP = 50; +const RULERS_TEXT_STEP = 100; + +/** + * The RulersHighlighter is a class that displays both horizontal and + * vertical rules on the page, along the top and left edges, with pixel + * graduations, useful for users to quickly check distances + */ +function RulersHighlighter(highlighterEnv) { + this.env = highlighterEnv; + this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv, + this._buildMarkup.bind(this)); + + let { pageListenerTarget } = highlighterEnv; + pageListenerTarget.addEventListener("scroll", this); + pageListenerTarget.addEventListener("pagehide", this); +} + +RulersHighlighter.prototype = { + typeName: "RulersHighlighter", + + ID_CLASS_PREFIX: "rulers-highlighter-", + + _buildMarkup: function () { + let { window } = this.env; + let prefix = this.ID_CLASS_PREFIX; + + function createRuler(axis, size) { + let width, height; + let isHorizontal = true; + + if (axis === "x") { + width = size; + height = 16; + } else if (axis === "y") { + width = 16; + height = size; + isHorizontal = false; + } else { + throw new Error( + `Invalid type of axis given; expected "x" or "y" but got "${axis}"`); + } + + let g = createSVGNode(window, { + nodeType: "g", + attributes: { + id: `${axis}-axis` + }, + parent: svg, + prefix + }); + + createSVGNode(window, { + nodeType: "rect", + attributes: { + y: isHorizontal ? 0 : 16, + width, + height + }, + parent: g + }); + + let gRule = createSVGNode(window, { + nodeType: "g", + attributes: { + id: `${axis}-axis-ruler` + }, + parent: g, + prefix + }); + + let pathGraduations = createSVGNode(window, { + nodeType: "path", + attributes: { + "class": "ruler-graduations", + width, + height + }, + parent: gRule, + prefix + }); + + let pathMarkers = createSVGNode(window, { + nodeType: "path", + attributes: { + "class": "ruler-markers", + width, + height + }, + parent: gRule, + prefix + }); + + let gText = createSVGNode(window, { + nodeType: "g", + attributes: { + id: `${axis}-axis-text`, + "class": (isHorizontal ? "horizontal" : "vertical") + "-labels" + }, + parent: g, + prefix + }); + + let dGraduations = ""; + let dMarkers = ""; + let graduationLength; + + for (let i = 0; i < size; i += RULERS_GRADUATION_STEP) { + if (i === 0) { + continue; + } + + graduationLength = (i % 2 === 0) ? 6 : 4; + + if (i % RULERS_TEXT_STEP === 0) { + graduationLength = 8; + createSVGNode(window, { + nodeType: "text", + parent: gText, + attributes: { + x: isHorizontal ? 2 + i : -i - 1, + y: 5 + } + }).textContent = i; + } + + if (isHorizontal) { + if (i % RULERS_MARKER_STEP === 0) { + dMarkers += `M${i} 0 L${i} ${graduationLength}`; + } else { + dGraduations += `M${i} 0 L${i} ${graduationLength} `; + } + } else { + if (i % 50 === 0) { + dMarkers += `M0 ${i} L${graduationLength} ${i}`; + } else { + dGraduations += `M0 ${i} L${graduationLength} ${i}`; + } + } + } + + pathGraduations.setAttribute("d", dGraduations); + pathMarkers.setAttribute("d", dMarkers); + + return g; + } + + let container = createNode(window, { + attributes: {"class": "highlighter-container"} + }); + + let root = createNode(window, { + parent: container, + attributes: { + "id": "root", + "class": "root" + }, + prefix + }); + + let svg = createSVGNode(window, { + nodeType: "svg", + parent: root, + attributes: { + id: "elements", + "class": "elements", + width: "100%", + height: "100%", + hidden: "true" + }, + prefix + }); + + createRuler("x", RULERS_MAX_X_AXIS); + createRuler("y", RULERS_MAX_Y_AXIS); + + return container; + }, + + handleEvent: function (event) { + switch (event.type) { + case "scroll": + this._onScroll(event); + break; + case "pagehide": + this.destroy(); + break; + } + }, + + _onScroll: function (event) { + let prefix = this.ID_CLASS_PREFIX; + let { scrollX, scrollY } = event.view; + + this.markup.getElement(`${prefix}x-axis-ruler`) + .setAttribute("transform", `translate(${-scrollX})`); + this.markup.getElement(`${prefix}x-axis-text`) + .setAttribute("transform", `translate(${-scrollX})`); + this.markup.getElement(`${prefix}y-axis-ruler`) + .setAttribute("transform", `translate(0, ${-scrollY})`); + this.markup.getElement(`${prefix}y-axis-text`) + .setAttribute("transform", `translate(0, ${-scrollY})`); + }, + + _update: function () { + let { window } = this.env; + + setIgnoreLayoutChanges(true); + + let zoom = getCurrentZoom(window); + let isZoomChanged = zoom !== this._zoom; + + if (isZoomChanged) { + this._zoom = zoom; + this.updateViewport(); + } + + setIgnoreLayoutChanges(false, window.document.documentElement); + + this._rafID = window.requestAnimationFrame(() => this._update()); + }, + + _cancelUpdate: function () { + if (this._rafID) { + this.env.window.cancelAnimationFrame(this._rafID); + this._rafID = 0; + } + }, + updateViewport: function () { + let { devicePixelRatio } = this.env.window; + + // Because `devicePixelRatio` is affected by zoom (see bug 809788), + // in order to get the "real" device pixel ratio, we need divide by `zoom` + let pixelRatio = devicePixelRatio / this._zoom; + + // The "real" device pixel ratio is used to calculate the max stroke + // width we can actually assign: on retina, for instance, it would be 0.5, + // where on non high dpi monitor would be 1. + let minWidth = 1 / pixelRatio; + let strokeWidth = Math.min(minWidth, minWidth / this._zoom); + + this.markup.getElement(this.ID_CLASS_PREFIX + "root").setAttribute("style", + `stroke-width:${strokeWidth};`); + }, + + destroy: function () { + this.hide(); + + let { pageListenerTarget } = this.env; + pageListenerTarget.removeEventListener("scroll", this); + pageListenerTarget.removeEventListener("pagehide", this); + + this.markup.destroy(); + + events.emit(this, "destroy"); + }, + + show: function () { + this.markup.removeAttributeForElement(this.ID_CLASS_PREFIX + "elements", + "hidden"); + + this._update(); + + return true; + }, + + hide: function () { + this.markup.setAttributeForElement(this.ID_CLASS_PREFIX + "elements", + "hidden", "true"); + + this._cancelUpdate(); + } +}; +exports.RulersHighlighter = RulersHighlighter; diff --git a/devtools/server/actors/highlighters/selector.js b/devtools/server/actors/highlighters/selector.js new file mode 100644 index 000000000..557a6d541 --- /dev/null +++ b/devtools/server/actors/highlighters/selector.js @@ -0,0 +1,83 @@ +/* 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 { isNodeValid } = require("./utils/markup"); +const { BoxModelHighlighter } = require("./box-model"); + +// How many maximum nodes can be highlighted at the same time by the +// SelectorHighlighter +const MAX_HIGHLIGHTED_ELEMENTS = 100; + +/** + * The SelectorHighlighter runs a given selector through querySelectorAll on the + * document of the provided context node and then uses the BoxModelHighlighter + * to highlight the matching nodes + */ +function SelectorHighlighter(highlighterEnv) { + this.highlighterEnv = highlighterEnv; + this._highlighters = []; +} + +SelectorHighlighter.prototype = { + typeName: "SelectorHighlighter", + + /** + * Show BoxModelHighlighter on each node that matches that provided selector. + * @param {DOMNode} node A context node that is used to get the document on + * which querySelectorAll should be executed. This node will NOT be + * highlighted. + * @param {Object} options Should at least contain the 'selector' option, a + * string that will be used in querySelectorAll. On top of this, all of the + * valid options to BoxModelHighlighter.show are also valid here. + */ + show: function (node, options = {}) { + this.hide(); + + if (!isNodeValid(node) || !options.selector) { + return false; + } + + let nodes = []; + try { + nodes = [...node.ownerDocument.querySelectorAll(options.selector)]; + } catch (e) { + // It's fine if the provided selector is invalid, nodes will be an empty + // array. + } + + delete options.selector; + + let i = 0; + for (let matchingNode of nodes) { + if (i >= MAX_HIGHLIGHTED_ELEMENTS) { + break; + } + + let highlighter = new BoxModelHighlighter(this.highlighterEnv); + if (options.fill) { + highlighter.regionFill[options.region || "border"] = options.fill; + } + highlighter.show(matchingNode, options); + this._highlighters.push(highlighter); + i++; + } + + return true; + }, + + hide: function () { + for (let highlighter of this._highlighters) { + highlighter.destroy(); + } + this._highlighters = []; + }, + + destroy: function () { + this.hide(); + this.highlighterEnv = null; + } +}; +exports.SelectorHighlighter = SelectorHighlighter; diff --git a/devtools/server/actors/highlighters/simple-outline.js b/devtools/server/actors/highlighters/simple-outline.js new file mode 100644 index 000000000..dae20f2d9 --- /dev/null +++ b/devtools/server/actors/highlighters/simple-outline.js @@ -0,0 +1,67 @@ +/* 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 { + installHelperSheet, + isNodeValid, + addPseudoClassLock, + removePseudoClassLock +} = require("./utils/markup"); + +// SimpleOutlineHighlighter's stylesheet +const HIGHLIGHTED_PSEUDO_CLASS = ":-moz-devtools-highlighted"; +const SIMPLE_OUTLINE_SHEET = `.__fx-devtools-hide-shortcut__ { + visibility: hidden !important + } + ${HIGHLIGHTED_PSEUDO_CLASS} { + outline: 2px dashed #F06!important; + outline-offset: -2px!important + }`; +/** + * The SimpleOutlineHighlighter is a class that has the same API than the + * BoxModelHighlighter, but adds a pseudo-class on the target element itself + * to draw a simple css outline around the element. + * It is used by the HighlighterActor when canvasframe-based highlighters can't + * be used. This is the case for XUL windows. + */ +function SimpleOutlineHighlighter(highlighterEnv) { + this.chromeDoc = highlighterEnv.document; +} + +SimpleOutlineHighlighter.prototype = { + /** + * Destroy the nodes. Remove listeners. + */ + destroy: function () { + this.hide(); + this.chromeDoc = null; + }, + + /** + * Show the highlighter on a given node + * @param {DOMNode} node + */ + show: function (node) { + if (isNodeValid(node) && (!this.currentNode || node !== this.currentNode)) { + this.hide(); + this.currentNode = node; + installHelperSheet(node.ownerDocument.defaultView, SIMPLE_OUTLINE_SHEET); + addPseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS); + } + return true; + }, + + /** + * Hide the highlighter, the outline and the infobar. + */ + hide: function () { + if (this.currentNode) { + removePseudoClassLock(this.currentNode, HIGHLIGHTED_PSEUDO_CLASS); + this.currentNode = null; + } + } +}; +exports.SimpleOutlineHighlighter = SimpleOutlineHighlighter; diff --git a/devtools/server/actors/highlighters/utils/markup.js b/devtools/server/actors/highlighters/utils/markup.js new file mode 100644 index 000000000..8750014bc --- /dev/null +++ b/devtools/server/actors/highlighters/utils/markup.js @@ -0,0 +1,609 @@ +/* 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 { Cc, Ci, Cu } = require("chrome"); +const { getCurrentZoom, + getRootBindingParent } = require("devtools/shared/layout/utils"); +const { on, emit } = require("sdk/event/core"); + +const lazyContainer = {}; + +loader.lazyRequireGetter(lazyContainer, "CssLogic", + "devtools/server/css-logic", true); +exports.getComputedStyle = (node) => + lazyContainer.CssLogic.getComputedStyle(node); + +exports.getBindingElementAndPseudo = (node) => + lazyContainer.CssLogic.getBindingElementAndPseudo(node); + +loader.lazyGetter(lazyContainer, "DOMUtils", () => + Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils)); +exports.hasPseudoClassLock = (...args) => + lazyContainer.DOMUtils.hasPseudoClassLock(...args); + +exports.addPseudoClassLock = (...args) => + lazyContainer.DOMUtils.addPseudoClassLock(...args); + +exports.removePseudoClassLock = (...args) => + lazyContainer.DOMUtils.removePseudoClassLock(...args); + +exports.getCSSStyleRules = (...args) => + lazyContainer.DOMUtils.getCSSStyleRules(...args); + +const SVG_NS = "http://www.w3.org/2000/svg"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const STYLESHEET_URI = "resource://devtools/server/actors/" + + "highlighters.css"; +// How high is the infobar (px). +const INFOBAR_HEIGHT = 34; +// What's the size of the infobar arrow (px). +const INFOBAR_ARROW_SIZE = 9; + +const _tokens = Symbol("classList/tokens"); + +/** + * Shims the element's `classList` for anonymous content elements; used + * internally by `CanvasFrameAnonymousContentHelper.getElement()` method. + */ +function ClassList(className) { + let trimmed = (className || "").trim(); + this[_tokens] = trimmed ? trimmed.split(/\s+/) : []; +} + +ClassList.prototype = { + item(index) { + return this[_tokens][index]; + }, + contains(token) { + return this[_tokens].includes(token); + }, + add(token) { + if (!this.contains(token)) { + this[_tokens].push(token); + } + emit(this, "update"); + }, + remove(token) { + let index = this[_tokens].indexOf(token); + + if (index > -1) { + this[_tokens].splice(index, 1); + } + emit(this, "update"); + }, + toggle(token) { + if (this.contains(token)) { + this.remove(token); + } else { + this.add(token); + } + }, + get length() { + return this[_tokens].length; + }, + [Symbol.iterator]: function* () { + for (let i = 0; i < this.tokens.length; i++) { + yield this[_tokens][i]; + } + }, + toString() { + return this[_tokens].join(" "); + } +}; + +/** + * Is this content window a XUL window? + * @param {Window} window + * @return {Boolean} + */ +function isXUL(window) { + return window.document.documentElement.namespaceURI === XUL_NS; +} +exports.isXUL = isXUL; + +/** + * Inject a helper stylesheet in the window. + */ +var installedHelperSheets = new WeakMap(); + +function installHelperSheet(win, source, type = "agent") { + if (installedHelperSheets.has(win.document)) { + return; + } + let {Style} = require("sdk/stylesheet/style"); + let {attach} = require("sdk/content/mod"); + let style = Style({source, type}); + attach(style, win); + installedHelperSheets.set(win.document, style); +} +exports.installHelperSheet = installHelperSheet; + +/** + * Returns true if a DOM node is "valid", where "valid" means that the node isn't a dead + * object wrapper, is still attached to a document, and is of a given type. + * @param {DOMNode} node + * @param {Number} nodeType Optional, defaults to ELEMENT_NODE + * @return {Boolean} + */ +function isNodeValid(node, nodeType = Ci.nsIDOMNode.ELEMENT_NODE) { + // Is it still alive? + if (!node || Cu.isDeadWrapper(node)) { + return false; + } + + // Is it of the right type? + if (node.nodeType !== nodeType) { + return false; + } + + // Is its document accessible? + let doc = node.ownerDocument; + if (!doc || !doc.defaultView) { + return false; + } + + // Is the node connected to the document? Using getBindingParent adds + // support for anonymous elements generated by a node in the document. + let bindingParent = getRootBindingParent(node); + if (!doc.documentElement.contains(bindingParent)) { + return false; + } + + return true; +} +exports.isNodeValid = isNodeValid; + +/** + * Helper function that creates SVG DOM nodes. + * @param {Window} This window's document will be used to create the element + * @param {Object} Options for the node include: + * - nodeType: the type of node, defaults to "box". + * - attributes: a {name:value} object to be used as attributes for the node. + * - prefix: a string that will be used to prefix the values of the id and class + * attributes. + * - parent: if provided, the newly created element will be appended to this + * node. + */ +function createSVGNode(win, options) { + if (!options.nodeType) { + options.nodeType = "box"; + } + options.namespace = SVG_NS; + return createNode(win, options); +} +exports.createSVGNode = createSVGNode; + +/** + * Helper function that creates DOM nodes. + * @param {Window} This window's document will be used to create the element + * @param {Object} Options for the node include: + * - nodeType: the type of node, defaults to "div". + * - namespace: if passed, doc.createElementNS will be used instead of + * doc.creatElement. + * - attributes: a {name:value} object to be used as attributes for the node. + * - prefix: a string that will be used to prefix the values of the id and class + * attributes. + * - parent: if provided, the newly created element will be appended to this + * node. + */ +function createNode(win, options) { + let type = options.nodeType || "div"; + + let node; + if (options.namespace) { + node = win.document.createElementNS(options.namespace, type); + } else { + node = win.document.createElement(type); + } + + for (let name in options.attributes || {}) { + let value = options.attributes[name]; + if (options.prefix && (name === "class" || name === "id")) { + value = options.prefix + value; + } + node.setAttribute(name, value); + } + + if (options.parent) { + options.parent.appendChild(node); + } + + return node; +} +exports.createNode = createNode; + +/** + * Every highlighters should insert their markup content into the document's + * canvasFrame anonymous content container (see dom/webidl/Document.webidl). + * + * Since this container gets cleared when the document navigates, highlighters + * should use this helper to have their markup content automatically re-inserted + * in the new document. + * + * Since the markup content is inserted in the canvasFrame using + * insertAnonymousContent, this means that it can be modified using the API + * described in AnonymousContent.webidl. + * To retrieve the AnonymousContent instance, use the content getter. + * + * @param {HighlighterEnv} highlighterEnv + * The environemnt which windows will be used to insert the node. + * @param {Function} nodeBuilder + * A function that, when executed, returns a DOM node to be inserted into + * the canvasFrame. + */ +function CanvasFrameAnonymousContentHelper(highlighterEnv, nodeBuilder) { + this.highlighterEnv = highlighterEnv; + this.nodeBuilder = nodeBuilder; + this.anonymousContentDocument = this.highlighterEnv.document; + // XXX the next line is a wallpaper for bug 1123362. + this.anonymousContentGlobal = Cu.getGlobalForObject( + this.anonymousContentDocument); + + // Only try to create the highlighter when the document is loaded, + // otherwise, wait for the navigate event to fire. + let doc = this.highlighterEnv.document; + if (doc.documentElement && doc.readyState != "uninitialized") { + this._insert(); + } + + this._onNavigate = this._onNavigate.bind(this); + this.highlighterEnv.on("navigate", this._onNavigate); + + this.listeners = new Map(); +} + +CanvasFrameAnonymousContentHelper.prototype = { + destroy: function () { + try { + let doc = this.anonymousContentDocument; + doc.removeAnonymousContent(this._content); + } catch (e) { + // If the current window isn't the one the content was inserted into, this + // will fail, but that's fine. + } + this.highlighterEnv.off("navigate", this._onNavigate); + this.highlighterEnv = this.nodeBuilder = this._content = null; + this.anonymousContentDocument = null; + this.anonymousContentGlobal = null; + + this._removeAllListeners(); + }, + + _insert: function () { + let doc = this.highlighterEnv.document; + // Insert the content node only if the document: + // * is loaded (navigate event will fire once it is), + // * still exists, + // * isn't in XUL. + if (doc.readyState == "uninitialized" || + !doc.documentElement || + isXUL(this.highlighterEnv.window)) { + return; + } + + // For now highlighters.css is injected in content as a ua sheet because + // <style scoped> doesn't work inside anonymous content (see bug 1086532). + // If it did, highlighters.css would be injected as an anonymous content + // node using CanvasFrameAnonymousContentHelper instead. + installHelperSheet(this.highlighterEnv.window, + "@import url('" + STYLESHEET_URI + "');"); + let node = this.nodeBuilder(); + + // It was stated that hidden documents don't accept + // `insertAnonymousContent` calls yet. That doesn't seems the case anymore, + // at least on desktop. Therefore, removing the code that was dealing with + // that scenario, fixes when we're adding anonymous content in a tab that + // is not the active one (see bug 1260043 and bug 1260044) + this._content = doc.insertAnonymousContent(node); + }, + + _onNavigate: function (e, {isTopLevel}) { + if (isTopLevel) { + this._removeAllListeners(); + this._insert(); + this.anonymousContentDocument = this.highlighterEnv.document; + } + }, + + getTextContentForElement: function (id) { + if (!this.content) { + return null; + } + return this.content.getTextContentForElement(id); + }, + + setTextContentForElement: function (id, text) { + if (this.content) { + this.content.setTextContentForElement(id, text); + } + }, + + setAttributeForElement: function (id, name, value) { + if (this.content) { + this.content.setAttributeForElement(id, name, value); + } + }, + + getAttributeForElement: function (id, name) { + if (!this.content) { + return null; + } + return this.content.getAttributeForElement(id, name); + }, + + removeAttributeForElement: function (id, name) { + if (this.content) { + this.content.removeAttributeForElement(id, name); + } + }, + + hasAttributeForElement: function (id, name) { + return typeof this.getAttributeForElement(id, name) === "string"; + }, + + getCanvasContext: function (id, type = "2d") { + return this.content ? this.content.getCanvasContext(id, type) : null; + }, + + /** + * Add an event listener to one of the elements inserted in the canvasFrame + * native anonymous container. + * Like other methods in this helper, this requires the ID of the element to + * be passed in. + * + * Note that if the content page navigates, the event listeners won't be + * added again. + * + * Also note that unlike traditional DOM events, the events handled by + * listeners added here will propagate through the document only through + * bubbling phase, so the useCapture parameter isn't supported. + * It is possible however to call e.stopPropagation() to stop the bubbling. + * + * IMPORTANT: the chrome-only canvasFrame insertion API takes great care of + * not leaking references to inserted elements to chrome JS code. That's + * because otherwise, chrome JS code could freely modify native anon elements + * inside the canvasFrame and probably change things that are assumed not to + * change by the C++ code managing this frame. + * See https://wiki.mozilla.org/DevTools/Highlighter#The_AnonymousContent_API + * Unfortunately, the inserted nodes are still available via + * event.originalTarget, and that's what the event handler here uses to check + * that the event actually occured on the right element, but that also means + * consumers of this code would be able to access the inserted elements. + * Therefore, the originalTarget property will be nullified before the event + * is passed to your handler. + * + * IMPL DETAIL: A single event listener is added per event types only, at + * browser level and if the event originalTarget is found to have the provided + * ID, the callback is executed (and then IDs of parent nodes of the + * originalTarget are checked too). + * + * @param {String} id + * @param {String} type + * @param {Function} handler + */ + addEventListenerForElement: function (id, type, handler) { + if (typeof id !== "string") { + throw new Error("Expected a string ID in addEventListenerForElement but" + + " got: " + id); + } + + // If no one is listening for this type of event yet, add one listener. + if (!this.listeners.has(type)) { + let target = this.highlighterEnv.pageListenerTarget; + target.addEventListener(type, this, true); + // Each type entry in the map is a map of ids:handlers. + this.listeners.set(type, new Map()); + } + + let listeners = this.listeners.get(type); + listeners.set(id, handler); + }, + + /** + * Remove an event listener from one of the elements inserted in the + * canvasFrame native anonymous container. + * @param {String} id + * @param {String} type + */ + removeEventListenerForElement: function (id, type) { + let listeners = this.listeners.get(type); + if (!listeners) { + return; + } + listeners.delete(id); + + // If no one is listening for event type anymore, remove the listener. + if (!this.listeners.has(type)) { + let target = this.highlighterEnv.pageListenerTarget; + target.removeEventListener(type, this, true); + } + }, + + handleEvent: function (event) { + let listeners = this.listeners.get(event.type); + if (!listeners) { + return; + } + + // Hide the originalTarget property to avoid exposing references to native + // anonymous elements. See addEventListenerForElement's comment. + let isPropagationStopped = false; + let eventProxy = new Proxy(event, { + get: (obj, name) => { + if (name === "originalTarget") { + return null; + } else if (name === "stopPropagation") { + return () => { + isPropagationStopped = true; + }; + } + return obj[name]; + } + }); + + // Start at originalTarget, bubble through ancestors and call handlers when + // needed. + let node = event.originalTarget; + while (node) { + let handler = listeners.get(node.id); + if (handler) { + handler(eventProxy, node.id); + if (isPropagationStopped) { + break; + } + } + node = node.parentNode; + } + }, + + _removeAllListeners: function () { + if (this.highlighterEnv) { + let target = this.highlighterEnv.pageListenerTarget; + for (let [type] of this.listeners) { + target.removeEventListener(type, this, true); + } + } + this.listeners.clear(); + }, + + getElement: function (id) { + let classList = new ClassList(this.getAttributeForElement(id, "class")); + + on(classList, "update", () => { + this.setAttributeForElement(id, "class", classList.toString()); + }); + + return { + getTextContent: () => this.getTextContentForElement(id), + setTextContent: text => this.setTextContentForElement(id, text), + setAttribute: (name, val) => this.setAttributeForElement(id, name, val), + getAttribute: name => this.getAttributeForElement(id, name), + removeAttribute: name => this.removeAttributeForElement(id, name), + hasAttribute: name => this.hasAttributeForElement(id, name), + getCanvasContext: type => this.getCanvasContext(id, type), + addEventListener: (type, handler) => { + return this.addEventListenerForElement(id, type, handler); + }, + removeEventListener: (type, handler) => { + return this.removeEventListenerForElement(id, type, handler); + }, + classList + }; + }, + + get content() { + if (!this._content || Cu.isDeadWrapper(this._content)) { + return null; + } + return this._content; + }, + + /** + * The canvasFrame anonymous content container gets zoomed in/out with the + * page. If this is unwanted, i.e. if you want the inserted element to remain + * unzoomed, then this method can be used. + * + * Consumers of the CanvasFrameAnonymousContentHelper should call this method, + * it isn't executed automatically. Typically, AutoRefreshHighlighter can call + * it when _update is executed. + * + * The matching element will be scaled down or up by 1/zoomLevel (using css + * transform) to cancel the current zoom. The element's width and height + * styles will also be set according to the scale. Finally, the element's + * position will be set as absolute. + * + * Note that if the matching element already has an inline style attribute, it + * *won't* be preserved. + * + * @param {DOMNode} node This node is used to determine which container window + * should be used to read the current zoom value. + * @param {String} id The ID of the root element inserted with this API. + */ + scaleRootElement: function (node, id) { + let zoom = getCurrentZoom(node); + let value = "position:absolute;width:100%;height:100%;"; + + if (zoom !== 1) { + value = "position:absolute;"; + value += "transform-origin:top left;transform:scale(" + (1 / zoom) + ");"; + value += "width:" + (100 * zoom) + "%;height:" + (100 * zoom) + "%;"; + } + + this.setAttributeForElement(id, "style", value); + } +}; +exports.CanvasFrameAnonymousContentHelper = CanvasFrameAnonymousContentHelper; + +/** + * Move the infobar to the right place in the highlighter. This helper method is utilized + * in both css-grid.js and box-model.js to help position the infobar in an appropriate + * space over the highlighted node element or grid area. The infobar is used to display + * relevant information about the highlighted item (ex, node or grid name and dimensions). + * + * This method will first try to position the infobar to top or bottom of the container + * such that it has enough space for the height of the infobar. Afterwards, it will try + * to horizontally center align with the container element if possible. + * + * @param {DOMNode} container + * The container element which will be used to position the infobar. + * @param {Object} bounds + * The content bounds of the container element. + * @param {Window} win + * The window object. + */ +function moveInfobar(container, bounds, win) { + let winHeight = win.innerHeight * getCurrentZoom(win); + let winWidth = win.innerWidth * getCurrentZoom(win); + let winScrollY = win.scrollY; + + // Ensure that containerBottom and containerTop are at least zero to avoid + // showing tooltips outside the viewport. + let containerBottom = Math.max(0, bounds.bottom) + INFOBAR_ARROW_SIZE; + let containerTop = Math.min(winHeight, bounds.top); + + // Can the bar be above the node? + let top; + if (containerTop < INFOBAR_HEIGHT) { + // No. Can we move the bar under the node? + if (containerBottom + INFOBAR_HEIGHT > winHeight) { + // No. Let's move it inside. Can we show it at the top of the element? + if (containerTop < winScrollY) { + // No. Window is scrolled past the top of the element. + top = 0; + } else { + // Yes. Show it at the top of the element + top = containerTop; + } + container.setAttribute("position", "overlap"); + } else { + // Yes. Let's move it under the node. + top = containerBottom; + container.setAttribute("position", "bottom"); + } + } else { + // Yes. Let's move it on top of the node. + top = containerTop - INFOBAR_HEIGHT; + container.setAttribute("position", "top"); + } + + // Align the bar with the box's center if possible. + let left = bounds.right - bounds.width / 2; + // Make sure the while infobar is visible. + let buffer = 100; + if (left < buffer) { + left = buffer; + container.setAttribute("hide-arrow", "true"); + } else if (left > winWidth - buffer) { + left = winWidth - buffer; + container.setAttribute("hide-arrow", "true"); + } else { + container.removeAttribute("hide-arrow"); + } + + let style = "top:" + top + "px;left:" + left + "px;"; + container.setAttribute("style", style); +} +exports.moveInfobar = moveInfobar; diff --git a/devtools/server/actors/highlighters/utils/moz.build b/devtools/server/actors/highlighters/utils/moz.build new file mode 100644 index 000000000..4bb429bc3 --- /dev/null +++ b/devtools/server/actors/highlighters/utils/moz.build @@ -0,0 +1,9 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + 'markup.js' +) diff --git a/devtools/server/actors/inspector.js b/devtools/server/actors/inspector.js new file mode 100644 index 000000000..20a227a40 --- /dev/null +++ b/devtools/server/actors/inspector.js @@ -0,0 +1,3186 @@ +/* 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"; + +/** + * Here's the server side of the remote inspector. + * + * The WalkerActor is the client's view of the debuggee's DOM. It's gives + * the client a tree of NodeActor objects. + * + * The walker presents the DOM tree mostly unmodified from the source DOM + * tree, but with a few key differences: + * + * - Empty text nodes are ignored. This is pretty typical of developer + * tools, but maybe we should reconsider that on the server side. + * - iframes with documents loaded have the loaded document as the child, + * the walker provides one big tree for the whole document tree. + * + * There are a few ways to get references to NodeActors: + * + * - When you first get a WalkerActor reference, it comes with a free + * reference to the root document's node. + * - Given a node, you can ask for children, siblings, and parents. + * - You can issue querySelector and querySelectorAll requests to find + * other elements. + * - Requests that return arbitrary nodes from the tree (like querySelector + * and querySelectorAll) will also return any nodes the client hasn't + * seen in order to have a complete set of parents. + * + * Once you have a NodeFront, you should be able to answer a few questions + * without further round trips, like the node's name, namespace/tagName, + * attributes, etc. Other questions (like a text node's full nodeValue) + * might require another round trip. + * + * The protocol guarantees that the client will always know the parent of + * any node that is returned by the server. This means that some requests + * (like querySelector) will include the extra nodes needed to satisfy this + * requirement. The client keeps track of this parent relationship, so the + * node fronts form a tree that is a subset of the actual DOM tree. + * + * + * We maintain this guarantee to support the ability to release subtrees on + * the client - when a node is disconnected from the DOM tree we want to be + * able to free the client objects for all the children nodes. + * + * So to be able to answer "all the children of a given node that we have + * seen on the client side", we guarantee that every time we've seen a node, + * we connect it up through its parents. + */ + +const {Cc, Ci, Cu} = require("chrome"); +const Services = require("Services"); +const protocol = require("devtools/shared/protocol"); +const {LayoutActor} = require("devtools/server/actors/layout"); +const {LongStringActor} = require("devtools/server/actors/string"); +const promise = require("promise"); +const {Task} = require("devtools/shared/task"); +const events = require("sdk/event/core"); +const {WalkerSearch} = require("devtools/server/actors/utils/walker-search"); +const {PageStyleActor, getFontPreviewData} = require("devtools/server/actors/styles"); +const { + HighlighterActor, + CustomHighlighterActor, + isTypeRegistered, + HighlighterEnvironment +} = require("devtools/server/actors/highlighters"); +const {EyeDropper} = require("devtools/server/actors/highlighters/eye-dropper"); +const { + isAnonymous, + isNativeAnonymous, + isXBLAnonymous, + isShadowAnonymous, + getFrameElement +} = require("devtools/shared/layout/utils"); +const {getLayoutChangesObserver, releaseLayoutChangesObserver} = require("devtools/server/actors/reflow"); +const nodeFilterConstants = require("devtools/shared/dom-node-filter-constants"); + +const {EventParsers} = require("devtools/server/event-parsers"); +const {nodeSpec, nodeListSpec, walkerSpec, inspectorSpec} = require("devtools/shared/specs/inspector"); + +const FONT_FAMILY_PREVIEW_TEXT = "The quick brown fox jumps over the lazy dog"; +const FONT_FAMILY_PREVIEW_TEXT_SIZE = 20; +const PSEUDO_CLASSES = [":hover", ":active", ":focus"]; +const HIDDEN_CLASS = "__fx-devtools-hide-shortcut__"; +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const IMAGE_FETCHING_TIMEOUT = 500; +const RX_FUNC_NAME = + /((var|const|let)\s+)?([\w$.]+\s*[:=]\s*)*(function)?\s*\*?\s*([\w$]+)?\s*$/; + +// The possible completions to a ':' with added score to give certain values +// some preference. +const PSEUDO_SELECTORS = [ + [":active", 1], + [":hover", 1], + [":focus", 1], + [":visited", 0], + [":link", 0], + [":first-letter", 0], + [":first-child", 2], + [":before", 2], + [":after", 2], + [":lang(", 0], + [":not(", 3], + [":first-of-type", 0], + [":last-of-type", 0], + [":only-of-type", 0], + [":only-child", 2], + [":nth-child(", 3], + [":nth-last-child(", 0], + [":nth-of-type(", 0], + [":nth-last-of-type(", 0], + [":last-child", 2], + [":root", 0], + [":empty", 0], + [":target", 0], + [":enabled", 0], + [":disabled", 0], + [":checked", 1], + ["::selection", 0] +]; + +var HELPER_SHEET = ` + .__fx-devtools-hide-shortcut__ { + visibility: hidden !important; + } + + :-moz-devtools-highlighted { + outline: 2px dashed #F06!important; + outline-offset: -2px !important; + } +`; + +const flags = require("devtools/shared/flags"); + +loader.lazyRequireGetter(this, "DevToolsUtils", + "devtools/shared/DevToolsUtils"); + +loader.lazyRequireGetter(this, "AsyncUtils", "devtools/shared/async-utils"); + +loader.lazyGetter(this, "DOMParser", function () { + return Cc["@mozilla.org/xmlextras/domparser;1"] + .createInstance(Ci.nsIDOMParser); +}); + +loader.lazyGetter(this, "eventListenerService", function () { + return Cc["@mozilla.org/eventlistenerservice;1"] + .getService(Ci.nsIEventListenerService); +}); + +loader.lazyGetter(this, "CssLogic", () => require("devtools/server/css-logic").CssLogic); + +/** + * We only send nodeValue up to a certain size by default. This stuff + * controls that size. + */ +exports.DEFAULT_VALUE_SUMMARY_LENGTH = 50; +var gValueSummaryLength = exports.DEFAULT_VALUE_SUMMARY_LENGTH; + +exports.getValueSummaryLength = function () { + return gValueSummaryLength; +}; + +exports.setValueSummaryLength = function (val) { + gValueSummaryLength = val; +}; + +// When the user selects a node to inspect in e10s, the parent process +// has a CPOW that wraps the node being inspected. It uses the +// message manager to send this node to the child, which stores the +// node in gInspectingNode. Then a findInspectingNode request is sent +// over the remote debugging protocol, and gInspectingNode is returned +// to the parent as a NodeFront. +var gInspectingNode = null; + +// We expect this function to be called from the child.js frame script +// when it receives the node to be inspected over the message manager. +exports.setInspectingNode = function (val) { + gInspectingNode = val; +}; + +/** + * Returns the properly cased version of the node's tag name, which can be + * used when displaying said name in the UI. + * + * @param {Node} rawNode + * Node for which we want the display name + * @return {String} + * Properly cased version of the node tag name + */ +const getNodeDisplayName = function (rawNode) { + if (rawNode.nodeName && !rawNode.localName) { + // The localName & prefix APIs have been moved from the Node interface to the Element + // interface. Use Node.nodeName as a fallback. + return rawNode.nodeName; + } + return (rawNode.prefix ? rawNode.prefix + ":" : "") + rawNode.localName; +}; +exports.getNodeDisplayName = getNodeDisplayName; + +/** + * Server side of the node actor. + */ +var NodeActor = exports.NodeActor = protocol.ActorClassWithSpec(nodeSpec, { + initialize: function (walker, node) { + protocol.Actor.prototype.initialize.call(this, null); + this.walker = walker; + this.rawNode = node; + this._eventParsers = new EventParsers().parsers; + + // Storing the original display of the node, to track changes when reflows + // occur + this.wasDisplayed = this.isDisplayed; + }, + + toString: function () { + return "[NodeActor " + this.actorID + " for " + + this.rawNode.toString() + "]"; + }, + + /** + * Instead of storing a connection object, the NodeActor gets its connection + * from its associated walker. + */ + get conn() { + return this.walker.conn; + }, + + isDocumentElement: function () { + return this.rawNode.ownerDocument && + this.rawNode.ownerDocument.documentElement === this.rawNode; + }, + + destroy: function () { + protocol.Actor.prototype.destroy.call(this); + + if (this.mutationObserver) { + if (!Cu.isDeadWrapper(this.mutationObserver)) { + this.mutationObserver.disconnect(); + } + this.mutationObserver = null; + } + this.rawNode = null; + this.walker = null; + }, + + // Returns the JSON representation of this object over the wire. + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let parentNode = this.walker.parentNode(this); + let inlineTextChild = this.walker.inlineTextChild(this); + + let form = { + actor: this.actorID, + baseURI: this.rawNode.baseURI, + parent: parentNode ? parentNode.actorID : undefined, + nodeType: this.rawNode.nodeType, + namespaceURI: this.rawNode.namespaceURI, + nodeName: this.rawNode.nodeName, + nodeValue: this.rawNode.nodeValue, + displayName: getNodeDisplayName(this.rawNode), + numChildren: this.numChildren, + inlineTextChild: inlineTextChild ? inlineTextChild.form() : undefined, + + // doctype attributes + name: this.rawNode.name, + publicId: this.rawNode.publicId, + systemId: this.rawNode.systemId, + + attrs: this.writeAttrs(), + isBeforePseudoElement: this.isBeforePseudoElement, + isAfterPseudoElement: this.isAfterPseudoElement, + isAnonymous: isAnonymous(this.rawNode), + isNativeAnonymous: isNativeAnonymous(this.rawNode), + isXBLAnonymous: isXBLAnonymous(this.rawNode), + isShadowAnonymous: isShadowAnonymous(this.rawNode), + pseudoClassLocks: this.writePseudoClassLocks(), + + isDisplayed: this.isDisplayed, + isInHTMLDocument: this.rawNode.ownerDocument && + this.rawNode.ownerDocument.contentType === "text/html", + hasEventListeners: this._hasEventListeners, + }; + + if (this.isDocumentElement()) { + form.isDocumentElement = true; + } + + // Add an extra API for custom properties added by other + // modules/extensions. + form.setFormProperty = (name, value) => { + if (!form.props) { + form.props = {}; + } + form.props[name] = value; + }; + + // Fire an event so, other modules can create its own properties + // that should be passed to the client (within the form.props field). + events.emit(NodeActor, "form", { + target: this, + data: form + }); + + return form; + }, + + /** + * Watch the given document node for mutations using the DOM observer + * API. + */ + watchDocument: function (callback) { + let node = this.rawNode; + // Create the observer on the node's actor. The node will make sure + // the observer is cleaned up when the actor is released. + let observer = new node.defaultView.MutationObserver(callback); + observer.mergeAttributeRecords = true; + observer.observe(node, { + nativeAnonymousChildList: true, + attributes: true, + characterData: true, + characterDataOldValue: true, + childList: true, + subtree: true + }); + this.mutationObserver = observer; + }, + + get isBeforePseudoElement() { + return this.rawNode.nodeName === "_moz_generated_content_before"; + }, + + get isAfterPseudoElement() { + return this.rawNode.nodeName === "_moz_generated_content_after"; + }, + + // Estimate the number of children that the walker will return without making + // a call to children() if possible. + get numChildren() { + // For pseudo elements, childNodes.length returns 1, but the walker + // will return 0. + if (this.isBeforePseudoElement || this.isAfterPseudoElement) { + return 0; + } + + let rawNode = this.rawNode; + let numChildren = rawNode.childNodes.length; + let hasAnonChildren = rawNode.nodeType === Ci.nsIDOMNode.ELEMENT_NODE && + rawNode.ownerDocument.getAnonymousNodes(rawNode); + + let hasContentDocument = rawNode.contentDocument; + let hasSVGDocument = rawNode.getSVGDocument && rawNode.getSVGDocument(); + if (numChildren === 0 && (hasContentDocument || hasSVGDocument)) { + // This might be an iframe with virtual children. + numChildren = 1; + } + + // Normal counting misses ::before/::after. Also, some anonymous children + // may ultimately be skipped, so we have to consult with the walker. + if (numChildren === 0 || hasAnonChildren) { + numChildren = this.walker.children(this).nodes.length; + } + + return numChildren; + }, + + get computedStyle() { + return CssLogic.getComputedStyle(this.rawNode); + }, + + /** + * Is the node's display computed style value other than "none" + */ + get isDisplayed() { + // Consider all non-element nodes as displayed. + if (isNodeDead(this) || + this.rawNode.nodeType !== Ci.nsIDOMNode.ELEMENT_NODE || + this.isAfterPseudoElement || + this.isBeforePseudoElement) { + return true; + } + + let style = this.computedStyle; + if (!style) { + return true; + } + + return style.display !== "none"; + }, + + /** + * Are there event listeners that are listening on this node? This method + * uses all parsers registered via event-parsers.js.registerEventParser() to + * check if there are any event listeners. + */ + get _hasEventListeners() { + let parsers = this._eventParsers; + for (let [, {hasListeners}] of parsers) { + try { + if (hasListeners && hasListeners(this.rawNode)) { + return true; + } + } catch (e) { + // An object attached to the node looked like a listener but wasn't... + // do nothing. + } + } + return false; + }, + + writeAttrs: function () { + if (!this.rawNode.attributes) { + return undefined; + } + + return [...this.rawNode.attributes].map(attr => { + return {namespace: attr.namespace, name: attr.name, value: attr.value }; + }); + }, + + writePseudoClassLocks: function () { + if (this.rawNode.nodeType !== Ci.nsIDOMNode.ELEMENT_NODE) { + return undefined; + } + let ret = undefined; + for (let pseudo of PSEUDO_CLASSES) { + if (DOMUtils.hasPseudoClassLock(this.rawNode, pseudo)) { + ret = ret || []; + ret.push(pseudo); + } + } + return ret; + }, + + /** + * Gets event listeners and adds their information to the events array. + * + * @param {Node} node + * Node for which we are to get listeners. + */ + getEventListeners: function (node) { + let parsers = this._eventParsers; + let dbg = this.parent().tabActor.makeDebugger(); + let listeners = []; + + for (let [, {getListeners, normalizeHandler}] of parsers) { + try { + let eventInfos = getListeners(node); + + if (!eventInfos) { + continue; + } + + for (let eventInfo of eventInfos) { + if (normalizeHandler) { + eventInfo.normalizeHandler = normalizeHandler; + } + + this.processHandlerForEvent(node, listeners, dbg, eventInfo); + } + } catch (e) { + // An object attached to the node looked like a listener but wasn't... + // do nothing. + } + } + + listeners.sort((a, b) => { + return a.type.localeCompare(b.type); + }); + + return listeners; + }, + + /** + * Process a handler + * + * @param {Node} node + * The node for which we want information. + * @param {Array} events + * The events array contains all event objects that we have gathered + * so far. + * @param {Debugger} dbg + * JSDebugger instance. + * @param {Object} eventInfo + * See event-parsers.js.registerEventParser() for a description of the + * eventInfo object. + * + * @return {Array} + * An array of objects where a typical object looks like this: + * { + * type: "click", + * handler: function() { doSomething() }, + * origin: "http://www.mozilla.com", + * searchString: 'onclick="doSomething()"', + * tags: tags, + * DOM0: true, + * capturing: true, + * hide: { + * dom0: true + * } + * } + */ + processHandlerForEvent: function (node, listeners, dbg, eventInfo) { + let type = eventInfo.type || ""; + let handler = eventInfo.handler; + let tags = eventInfo.tags || ""; + let hide = eventInfo.hide || {}; + let override = eventInfo.override || {}; + let global = Cu.getGlobalForObject(handler); + let globalDO = dbg.addDebuggee(global); + let listenerDO = globalDO.makeDebuggeeValue(handler); + + if (eventInfo.normalizeHandler) { + listenerDO = eventInfo.normalizeHandler(listenerDO); + } + + // If the listener is an object with a 'handleEvent' method, use that. + if (listenerDO.class === "Object" || listenerDO.class === "XULElement") { + let desc; + + while (!desc && listenerDO) { + desc = listenerDO.getOwnPropertyDescriptor("handleEvent"); + listenerDO = listenerDO.proto; + } + + if (desc && desc.value) { + listenerDO = desc.value; + } + } + + if (listenerDO.isBoundFunction) { + listenerDO = listenerDO.boundTargetFunction; + } + + let script = listenerDO.script; + let scriptSource = script.source.text; + let functionSource = + scriptSource.substr(script.sourceStart, script.sourceLength); + + /* + The script returned is the whole script and + scriptSource.substr(script.sourceStart, script.sourceLength) returns + something like this: + () { doSomething(); } + + So we need to use some regex magic to get the appropriate function info + e.g.: + () => { ... } + function doit() { ... } + doit: function() { ... } + es6func() { ... } + var|let|const foo = function () { ... } + function generator*() { ... } + */ + let scriptBeforeFunc = scriptSource.substr(0, script.sourceStart); + let matches = scriptBeforeFunc.match(RX_FUNC_NAME); + if (matches && matches.length > 0) { + functionSource = matches[0].trim() + functionSource; + } + + let dom0 = false; + + if (typeof node.hasAttribute !== "undefined") { + dom0 = !!node.hasAttribute("on" + type); + } else { + dom0 = !!node["on" + type]; + } + + let line = script.startLine; + let url = script.url; + let origin = url + (dom0 ? "" : ":" + line); + let searchString; + + if (dom0) { + searchString = "on" + type + "=\"" + script.source.text + "\""; + } else { + scriptSource = " " + scriptSource; + } + + let eventObj = { + type: typeof override.type !== "undefined" ? override.type : type, + handler: functionSource.trim(), + origin: typeof override.origin !== "undefined" ? + override.origin : origin, + searchString: typeof override.searchString !== "undefined" ? + override.searchString : searchString, + tags: tags, + DOM0: typeof override.dom0 !== "undefined" ? override.dom0 : dom0, + capturing: typeof override.capturing !== "undefined" ? + override.capturing : eventInfo.capturing, + hide: hide + }; + + listeners.push(eventObj); + + dbg.removeDebuggee(globalDO); + }, + + /** + * Returns a LongStringActor with the node's value. + */ + getNodeValue: function () { + return new LongStringActor(this.conn, this.rawNode.nodeValue || ""); + }, + + /** + * Set the node's value to a given string. + */ + setNodeValue: function (value) { + this.rawNode.nodeValue = value; + }, + + /** + * Get a unique selector string for this node. + */ + getUniqueSelector: function () { + if (Cu.isDeadWrapper(this.rawNode)) { + return ""; + } + return CssLogic.findCssSelector(this.rawNode); + }, + + /** + * Scroll the selected node into view. + */ + scrollIntoView: function () { + this.rawNode.scrollIntoView(true); + }, + + /** + * Get the node's image data if any (for canvas and img nodes). + * Returns an imageData object with the actual data being a LongStringActor + * and a size json object. + * The image data is transmitted as a base64 encoded png data-uri. + * The method rejects if the node isn't an image or if the image is missing + * + * Accepts a maxDim request parameter to resize images that are larger. This + * is important as the resizing occurs server-side so that image-data being + * transfered in the longstring back to the client will be that much smaller + */ + getImageData: function (maxDim) { + return imageToImageData(this.rawNode, maxDim).then(imageData => { + return { + data: LongStringActor(this.conn, imageData.data), + size: imageData.size + }; + }); + }, + + /** + * Get all event listeners that are listening on this node. + */ + getEventListenerInfo: function () { + if (this.rawNode.nodeName.toLowerCase() === "html") { + return this.getEventListeners(this.rawNode.ownerGlobal); + } + return this.getEventListeners(this.rawNode); + }, + + /** + * Modify a node's attributes. Passed an array of modifications + * similar in format to "attributes" mutations. + * { + * attributeName: <string> + * attributeNamespace: <optional string> + * newValue: <optional string> - If null or undefined, the attribute + * will be removed. + * } + * + * Returns when the modifications have been made. Mutations will + * be queued for any changes made. + */ + modifyAttributes: function (modifications) { + let rawNode = this.rawNode; + for (let change of modifications) { + if (change.newValue == null) { + if (change.attributeNamespace) { + rawNode.removeAttributeNS(change.attributeNamespace, + change.attributeName); + } else { + rawNode.removeAttribute(change.attributeName); + } + } else if (change.attributeNamespace) { + rawNode.setAttributeNS(change.attributeNamespace, change.attributeName, + change.newValue); + } else { + rawNode.setAttribute(change.attributeName, change.newValue); + } + } + }, + + /** + * Given the font and fill style, get the image data of a canvas with the + * preview text and font. + * Returns an imageData object with the actual data being a LongStringActor + * and the width of the text as a string. + * The image data is transmitted as a base64 encoded png data-uri. + */ + getFontFamilyDataURL: function (font, fillStyle = "black") { + let doc = this.rawNode.ownerDocument; + let options = { + previewText: FONT_FAMILY_PREVIEW_TEXT, + previewFontSize: FONT_FAMILY_PREVIEW_TEXT_SIZE, + fillStyle: fillStyle + }; + let { dataURL, size } = getFontPreviewData(font, doc, options); + + return { data: LongStringActor(this.conn, dataURL), size: size }; + } +}); + +/** + * Server side of a node list as returned by querySelectorAll() + */ +var NodeListActor = exports.NodeListActor = protocol.ActorClassWithSpec(nodeListSpec, { + typeName: "domnodelist", + + initialize: function (walker, nodeList) { + protocol.Actor.prototype.initialize.call(this); + this.walker = walker; + this.nodeList = nodeList || []; + }, + + destroy: function () { + protocol.Actor.prototype.destroy.call(this); + }, + + /** + * Instead of storing a connection object, the NodeActor gets its connection + * from its associated walker. + */ + get conn() { + return this.walker.conn; + }, + + /** + * Items returned by this actor should belong to the parent walker. + */ + marshallPool: function () { + return this.walker; + }, + + // Returns the JSON representation of this object over the wire. + form: function () { + return { + actor: this.actorID, + length: this.nodeList ? this.nodeList.length : 0 + }; + }, + + /** + * Get a single node from the node list. + */ + item: function (index) { + return this.walker.attachElement(this.nodeList[index]); + }, + + /** + * Get a range of the items from the node list. + */ + items: function (start = 0, end = this.nodeList.length) { + let items = Array.prototype.slice.call(this.nodeList, start, end) + .map(item => this.walker._ref(item)); + return this.walker.attachElements(items); + }, + + release: function () {} +}); + +/** + * Server side of the DOM walker. + */ +var WalkerActor = protocol.ActorClassWithSpec(walkerSpec, { + /** + * Create the WalkerActor + * @param DebuggerServerConnection conn + * The server connection. + */ + initialize: function (conn, tabActor, options) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this.rootWin = tabActor.window; + this.rootDoc = this.rootWin.document; + this._refMap = new Map(); + this._pendingMutations = []; + this._activePseudoClassLocks = new Set(); + this.showAllAnonymousContent = options.showAllAnonymousContent; + + this.walkerSearch = new WalkerSearch(this); + + // Nodes which have been removed from the client's known + // ownership tree are considered "orphaned", and stored in + // this set. + this._orphaned = new Set(); + + // The client can tell the walker that it is interested in a node + // even when it is orphaned with the `retainNode` method. This + // list contains orphaned nodes that were so retained. + this._retainedOrphans = new Set(); + + this.onMutations = this.onMutations.bind(this); + this.onFrameLoad = this.onFrameLoad.bind(this); + this.onFrameUnload = this.onFrameUnload.bind(this); + + events.on(tabActor, "will-navigate", this.onFrameUnload); + events.on(tabActor, "navigate", this.onFrameLoad); + + // Ensure that the root document node actor is ready and + // managed. + this.rootNode = this.document(); + + this.layoutChangeObserver = getLayoutChangesObserver(this.tabActor); + this._onReflows = this._onReflows.bind(this); + this.layoutChangeObserver.on("reflows", this._onReflows); + this._onResize = this._onResize.bind(this); + this.layoutChangeObserver.on("resize", this._onResize); + + this._onEventListenerChange = this._onEventListenerChange.bind(this); + eventListenerService.addListenerChangeListener(this._onEventListenerChange); + }, + + /** + * Callback for eventListenerService.addListenerChangeListener + * @param nsISimpleEnumerator changesEnum + * enumerator of nsIEventListenerChange + */ + _onEventListenerChange: function (changesEnum) { + let changes = changesEnum.enumerate(); + while (changes.hasMoreElements()) { + let current = changes.getNext().QueryInterface(Ci.nsIEventListenerChange); + let target = current.target; + + if (this._refMap.has(target)) { + let actor = this.getNode(target); + let mutation = { + type: "events", + target: actor.actorID, + hasEventListeners: actor._hasEventListeners + }; + this.queueMutation(mutation); + } + } + }, + + // Returns the JSON representation of this object over the wire. + form: function () { + return { + actor: this.actorID, + root: this.rootNode.form(), + traits: { + // FF42+ Inspector starts managing the Walker, while the inspector also + // starts cleaning itself up automatically on client disconnection. + // So that there is no need to manually release the walker anymore. + autoReleased: true, + // XXX: It seems silly that we need to tell the front which capabilities + // its actor has in this way when the target can use actorHasMethod. If + // this was ported to the protocol (Bug 1157048) we could call that + // inside of custom front methods and not need to do traits for this. + multiFrameQuerySelectorAll: true, + textSearch: true, + } + }; + }, + + toString: function () { + return "[WalkerActor " + this.actorID + "]"; + }, + + getDocumentWalker: function (node, whatToShow) { + // Allow native anon content (like <video> controls) if preffed on + let nodeFilter = this.showAllAnonymousContent + ? allAnonymousContentTreeWalkerFilter + : standardTreeWalkerFilter; + return new DocumentWalker(node, this.rootWin, whatToShow, nodeFilter); + }, + + destroy: function () { + if (this._destroyed) { + return; + } + this._destroyed = true; + protocol.Actor.prototype.destroy.call(this); + try { + this.clearPseudoClassLocks(); + this._activePseudoClassLocks = null; + + this._hoveredNode = null; + this.rootWin = null; + this.rootDoc = null; + this.rootNode = null; + this.layoutHelpers = null; + this._orphaned = null; + this._retainedOrphans = null; + this._refMap = null; + + events.off(this.tabActor, "will-navigate", this.onFrameUnload); + events.off(this.tabActor, "navigate", this.onFrameLoad); + + this.onFrameLoad = null; + this.onFrameUnload = null; + + this.walkerSearch.destroy(); + + this.layoutChangeObserver.off("reflows", this._onReflows); + this.layoutChangeObserver.off("resize", this._onResize); + this.layoutChangeObserver = null; + releaseLayoutChangesObserver(this.tabActor); + + eventListenerService.removeListenerChangeListener( + this._onEventListenerChange); + + this.onMutations = null; + + this.layoutActor = null; + this.tabActor = null; + + events.emit(this, "destroyed"); + } catch (e) { + console.error(e); + } + }, + + release: function () {}, + + unmanage: function (actor) { + if (actor instanceof NodeActor) { + if (this._activePseudoClassLocks && + this._activePseudoClassLocks.has(actor)) { + this.clearPseudoClassLocks(actor); + } + this._refMap.delete(actor.rawNode); + } + protocol.Actor.prototype.unmanage.call(this, actor); + }, + + /** + * Determine if the walker has come across this DOM node before. + * @param {DOMNode} rawNode + * @return {Boolean} + */ + hasNode: function (rawNode) { + return this._refMap.has(rawNode); + }, + + /** + * If the walker has come across this DOM node before, then get the + * corresponding node actor. + * @param {DOMNode} rawNode + * @return {NodeActor} + */ + getNode: function (rawNode) { + return this._refMap.get(rawNode); + }, + + _ref: function (node) { + let actor = this.getNode(node); + if (actor) { + return actor; + } + + actor = new NodeActor(this, node); + + // Add the node actor as a child of this walker actor, assigning + // it an actorID. + this.manage(actor); + this._refMap.set(node, actor); + + if (node.nodeType === Ci.nsIDOMNode.DOCUMENT_NODE) { + actor.watchDocument(this.onMutations); + } + return actor; + }, + + _onReflows: function (reflows) { + // Going through the nodes the walker knows about, see which ones have + // had their display changed and send a display-change event if any + let changes = []; + for (let [node, actor] of this._refMap) { + if (Cu.isDeadWrapper(node)) { + continue; + } + + let isDisplayed = actor.isDisplayed; + if (isDisplayed !== actor.wasDisplayed) { + changes.push(actor); + // Updating the original value + actor.wasDisplayed = isDisplayed; + } + } + + if (changes.length) { + events.emit(this, "display-change", changes); + } + }, + + /** + * When the browser window gets resized, relay the event to the front. + */ + _onResize: function () { + events.emit(this, "resize"); + }, + + /** + * This is kept for backward-compatibility reasons with older remote targets. + * Targets prior to bug 916443. + * + * pick/cancelPick are used to pick a node on click on the content + * document. But in their implementation prior to bug 916443, they don't allow + * highlighting on hover. + * The client-side now uses the highlighter actor's pick and cancelPick + * methods instead. The client-side uses the the highlightable trait found in + * the root actor to determine which version of pick to use. + * + * As for highlight, the new highlighter actor is used instead of the walker's + * highlight method. Same here though, the client-side uses the highlightable + * trait to dertermine which to use. + * + * Keeping these actor methods for now allows newer client-side debuggers to + * inspect fxos 1.2 remote targets or older firefox desktop remote targets. + */ + pick: function () {}, + cancelPick: function () {}, + highlight: function (node) {}, + + /** + * Ensures that the node is attached and it can be accessed from the root. + * + * @param {(Node|NodeActor)} nodes The nodes + * @return {Object} An object compatible with the disconnectedNode type. + */ + attachElement: function (node) { + let { nodes, newParents } = this.attachElements([node]); + return { + node: nodes[0], + newParents: newParents + }; + }, + + /** + * Ensures that the nodes are attached and they can be accessed from the root. + * + * @param {(Node[]|NodeActor[])} nodes The nodes + * @return {Object} An object compatible with the disconnectedNodeArray type. + */ + attachElements: function (nodes) { + let nodeActors = []; + let newParents = new Set(); + for (let node of nodes) { + if (!(node instanceof NodeActor)) { + // If an anonymous node was passed in and we aren't supposed to know + // about it, then consult with the document walker as the source of + // truth about which elements exist. + if (!this.showAllAnonymousContent && isAnonymous(node)) { + node = this.getDocumentWalker(node).currentNode; + } + + node = this._ref(node); + } + + this.ensurePathToRoot(node, newParents); + // If nodes may be an array of raw nodes, we're sure to only have + // NodeActors with the following array. + nodeActors.push(node); + } + + return { + nodes: nodeActors, + newParents: [...newParents] + }; + }, + + /** + * Return the document node that contains the given node, + * or the root node if no node is specified. + * @param NodeActor node + * The node whose document is needed, or null to + * return the root. + */ + document: function (node) { + let doc = isNodeDead(node) ? this.rootDoc : nodeDocument(node.rawNode); + return this._ref(doc); + }, + + /** + * Return the documentElement for the document containing the + * given node. + * @param NodeActor node + * The node whose documentElement is requested, or null + * to use the root document. + */ + documentElement: function (node) { + let elt = isNodeDead(node) + ? this.rootDoc.documentElement + : nodeDocument(node.rawNode).documentElement; + return this._ref(elt); + }, + + /** + * Return all parents of the given node, ordered from immediate parent + * to root. + * @param NodeActor node + * The node whose parents are requested. + * @param object options + * Named options, including: + * `sameDocument`: If true, parents will be restricted to the same + * document as the node. + * `sameTypeRootTreeItem`: If true, this will not traverse across + * different types of docshells. + */ + parents: function (node, options = {}) { + if (isNodeDead(node)) { + return []; + } + + let walker = this.getDocumentWalker(node.rawNode); + let parents = []; + let cur; + while ((cur = walker.parentNode())) { + if (options.sameDocument && + nodeDocument(cur) != nodeDocument(node.rawNode)) { + break; + } + + if (options.sameTypeRootTreeItem && + nodeDocshell(cur).sameTypeRootTreeItem != + nodeDocshell(node.rawNode).sameTypeRootTreeItem) { + break; + } + + parents.push(this._ref(cur)); + } + return parents; + }, + + parentNode: function (node) { + let walker = this.getDocumentWalker(node.rawNode); + let parent = walker.parentNode(); + if (parent) { + return this._ref(parent); + } + return null; + }, + + /** + * If the given NodeActor only has a single text node as a child with a text + * content small enough to be inlined, return that child's NodeActor. + * + * @param NodeActor node + */ + inlineTextChild: function (node) { + // Quick checks to prevent creating a new walker if possible. + if (node.isBeforePseudoElement || + node.isAfterPseudoElement || + node.rawNode.nodeType != Ci.nsIDOMNode.ELEMENT_NODE || + node.rawNode.children.length > 0) { + return undefined; + } + + let docWalker = this.getDocumentWalker(node.rawNode); + let firstChild = docWalker.firstChild(); + + // Bail out if: + // - more than one child + // - unique child is not a text node + // - unique child is a text node, but is too long to be inlined + if (!firstChild || + docWalker.nextSibling() || + firstChild.nodeType !== Ci.nsIDOMNode.TEXT_NODE || + firstChild.nodeValue.length > gValueSummaryLength + ) { + return undefined; + } + + return this._ref(firstChild); + }, + + /** + * Mark a node as 'retained'. + * + * A retained node is not released when `releaseNode` is called on its + * parent, or when a parent is released with the `cleanup` option to + * `getMutations`. + * + * When a retained node's parent is released, a retained mode is added to + * the walker's "retained orphans" list. + * + * Retained nodes can be deleted by providing the `force` option to + * `releaseNode`. They will also be released when their document + * has been destroyed. + * + * Retaining a node makes no promise about its children; They can + * still be removed by normal means. + */ + retainNode: function (node) { + node.retained = true; + }, + + /** + * Remove the 'retained' mark from a node. If the node was a + * retained orphan, release it. + */ + unretainNode: function (node) { + node.retained = false; + if (this._retainedOrphans.has(node)) { + this._retainedOrphans.delete(node); + this.releaseNode(node); + } + }, + + /** + * Release actors for a node and all child nodes. + */ + releaseNode: function (node, options = {}) { + if (isNodeDead(node)) { + return; + } + + if (node.retained && !options.force) { + this._retainedOrphans.add(node); + return; + } + + if (node.retained) { + // Forcing a retained node to go away. + this._retainedOrphans.delete(node); + } + + let walker = this.getDocumentWalker(node.rawNode); + + let child = walker.firstChild(); + while (child) { + let childActor = this.getNode(child); + if (childActor) { + this.releaseNode(childActor, options); + } + child = walker.nextSibling(); + } + + node.destroy(); + }, + + /** + * Add any nodes between `node` and the walker's root node that have not + * yet been seen by the client. + */ + ensurePathToRoot: function (node, newParents = new Set()) { + if (!node) { + return newParents; + } + let walker = this.getDocumentWalker(node.rawNode); + let cur; + while ((cur = walker.parentNode())) { + let parent = this.getNode(cur); + if (!parent) { + // This parent didn't exist, so hasn't been seen by the client yet. + newParents.add(this._ref(cur)); + } else { + // This parent did exist, so the client knows about it. + return newParents; + } + } + return newParents; + }, + + /** + * Return children of the given node. By default this method will return + * all children of the node, but there are options that can restrict this + * to a more manageable subset. + * + * @param NodeActor node + * The node whose children you're curious about. + * @param object options + * Named options: + * `maxNodes`: The set of nodes returned by the method will be no longer + * than maxNodes. + * `start`: If a node is specified, the list of nodes will start + * with the given child. Mutally exclusive with `center`. + * `center`: If a node is specified, the given node will be as centered + * as possible in the list, given how close to the ends of the child + * list it is. Mutually exclusive with `start`. + * `whatToShow`: A bitmask of node types that should be included. See + * https://developer.mozilla.org/en-US/docs/Web/API/NodeFilter. + * + * @returns an object with three items: + * hasFirst: true if the first child of the node is included in the list. + * hasLast: true if the last child of the node is included in the list. + * nodes: Child nodes returned by the request. + */ + children: function (node, options = {}) { + if (isNodeDead(node)) { + return { hasFirst: true, hasLast: true, nodes: [] }; + } + + if (options.center && options.start) { + throw Error("Can't specify both 'center' and 'start' options."); + } + let maxNodes = options.maxNodes || -1; + if (maxNodes == -1) { + maxNodes = Number.MAX_VALUE; + } + + // We're going to create a few document walkers with the same filter, + // make it easier. + let getFilteredWalker = documentWalkerNode => { + return this.getDocumentWalker(documentWalkerNode, options.whatToShow); + }; + + // Need to know the first and last child. + let rawNode = node.rawNode; + let firstChild = getFilteredWalker(rawNode).firstChild(); + let lastChild = getFilteredWalker(rawNode).lastChild(); + + if (!firstChild) { + // No children, we're done. + return { hasFirst: true, hasLast: true, nodes: [] }; + } + + let start; + if (options.center) { + start = options.center.rawNode; + } else if (options.start) { + start = options.start.rawNode; + } else { + start = firstChild; + } + + let nodes = []; + + // Start by reading backward from the starting point if we're centering... + let backwardWalker = getFilteredWalker(start); + if (start != firstChild && options.center) { + backwardWalker.previousSibling(); + let backwardCount = Math.floor(maxNodes / 2); + let backwardNodes = this._readBackward(backwardWalker, backwardCount); + nodes = backwardNodes; + } + + // Then read forward by any slack left in the max children... + let forwardWalker = getFilteredWalker(start); + let forwardCount = maxNodes - nodes.length; + nodes = nodes.concat(this._readForward(forwardWalker, forwardCount)); + + // If there's any room left, it means we've run all the way to the end. + // If we're centering, check if there are more items to read at the front. + let remaining = maxNodes - nodes.length; + if (options.center && remaining > 0 && nodes[0].rawNode != firstChild) { + let firstNodes = this._readBackward(backwardWalker, remaining); + + // Then put it all back together. + nodes = firstNodes.concat(nodes); + } + + return { + hasFirst: nodes[0].rawNode == firstChild, + hasLast: nodes[nodes.length - 1].rawNode == lastChild, + nodes: nodes + }; + }, + + /** + * Return siblings of the given node. By default this method will return + * all siblings of the node, but there are options that can restrict this + * to a more manageable subset. + * + * If `start` or `center` are not specified, this method will center on the + * node whose siblings are requested. + * + * @param NodeActor node + * The node whose children you're curious about. + * @param object options + * Named options: + * `maxNodes`: The set of nodes returned by the method will be no longer + * than maxNodes. + * `start`: If a node is specified, the list of nodes will start + * with the given child. Mutally exclusive with `center`. + * `center`: If a node is specified, the given node will be as centered + * as possible in the list, given how close to the ends of the child + * list it is. Mutually exclusive with `start`. + * `whatToShow`: A bitmask of node types that should be included. See + * https://developer.mozilla.org/en-US/docs/Web/API/NodeFilter. + * + * @returns an object with three items: + * hasFirst: true if the first child of the node is included in the list. + * hasLast: true if the last child of the node is included in the list. + * nodes: Child nodes returned by the request. + */ + siblings: function (node, options = {}) { + if (isNodeDead(node)) { + return { hasFirst: true, hasLast: true, nodes: [] }; + } + + let parentNode = this.getDocumentWalker(node.rawNode, options.whatToShow) + .parentNode(); + if (!parentNode) { + return { + hasFirst: true, + hasLast: true, + nodes: [node] + }; + } + + if (!(options.start || options.center)) { + options.center = node; + } + + return this.children(this._ref(parentNode), options); + }, + + /** + * Get the next sibling of a given node. Getting nodes one at a time + * might be inefficient, be careful. + * + * @param object options + * Named options: + * `whatToShow`: A bitmask of node types that should be included. See + * https://developer.mozilla.org/en-US/docs/Web/API/NodeFilter. + */ + nextSibling: function (node, options = {}) { + if (isNodeDead(node)) { + return null; + } + + let walker = this.getDocumentWalker(node.rawNode, options.whatToShow); + let sibling = walker.nextSibling(); + return sibling ? this._ref(sibling) : null; + }, + + /** + * Get the previous sibling of a given node. Getting nodes one at a time + * might be inefficient, be careful. + * + * @param object options + * Named options: + * `whatToShow`: A bitmask of node types that should be included. See + * https://developer.mozilla.org/en-US/docs/Web/API/NodeFilter. + */ + previousSibling: function (node, options = {}) { + if (isNodeDead(node)) { + return null; + } + + let walker = this.getDocumentWalker(node.rawNode, options.whatToShow); + let sibling = walker.previousSibling(); + return sibling ? this._ref(sibling) : null; + }, + + /** + * Helper function for the `children` method: Read forward in the sibling + * list into an array with `count` items, including the current node. + */ + _readForward: function (walker, count) { + let ret = []; + let node = walker.currentNode; + do { + ret.push(this._ref(node)); + node = walker.nextSibling(); + } while (node && --count); + return ret; + }, + + /** + * Helper function for the `children` method: Read backward in the sibling + * list into an array with `count` items, including the current node. + */ + _readBackward: function (walker, count) { + let ret = []; + let node = walker.currentNode; + do { + ret.push(this._ref(node)); + node = walker.previousSibling(); + } while (node && --count); + ret.reverse(); + return ret; + }, + + /** + * Return the node that the parent process has asked to + * inspect. This node is expected to be stored in gInspectingNode + * (which is set by a message manager message to the child.js frame + * script). The node is returned over the remote debugging protocol + * as a NodeFront. + */ + findInspectingNode: function () { + let node = gInspectingNode; + if (!node) { + return {}; + } + + return this.attachElement(node); + }, + + /** + * Return the first node in the document that matches the given selector. + * See https://developer.mozilla.org/en-US/docs/Web/API/Element.querySelector + * + * @param NodeActor baseNode + * @param string selector + */ + querySelector: function (baseNode, selector) { + if (isNodeDead(baseNode)) { + return {}; + } + + let node = baseNode.rawNode.querySelector(selector); + if (!node) { + return {}; + } + + return this.attachElement(node); + }, + + /** + * Return a NodeListActor with all nodes that match the given selector. + * See https://developer.mozilla.org/en-US/docs/Web/API/Element.querySelectorAll + * + * @param NodeActor baseNode + * @param string selector + */ + querySelectorAll: function (baseNode, selector) { + let nodeList = null; + + try { + nodeList = baseNode.rawNode.querySelectorAll(selector); + } catch (e) { + // Bad selector. Do nothing as the selector can come from a searchbox. + } + + return new NodeListActor(this, nodeList); + }, + + /** + * Get a list of nodes that match the given selector in all known frames of + * the current content page. + * @param {String} selector. + * @return {Array} + */ + _multiFrameQuerySelectorAll: function (selector) { + let nodes = []; + + for (let {document} of this.tabActor.windows) { + try { + nodes = [...nodes, ...document.querySelectorAll(selector)]; + } catch (e) { + // Bad selector. Do nothing as the selector can come from a searchbox. + } + } + + return nodes; + }, + + /** + * Return a NodeListActor with all nodes that match the given selector in all + * frames of the current content page. + * @param {String} selector + */ + multiFrameQuerySelectorAll: function (selector) { + return new NodeListActor(this, this._multiFrameQuerySelectorAll(selector)); + }, + + /** + * Search the document for a given string. + * Results will be searched with the walker-search module (searches through + * tag names, attribute names and values, and text contents). + * + * @returns {searchresult} + * - {NodeList} list + * - {Array<Object>} metadata. Extra information with indices that + * match up with node list. + */ + search: function (query) { + let results = this.walkerSearch.search(query); + let nodeList = new NodeListActor(this, results.map(r => r.node)); + + return { + list: nodeList, + metadata: [] + }; + }, + + /** + * Returns a list of matching results for CSS selector autocompletion. + * + * @param string query + * The selector query being completed + * @param string completing + * The exact token being completed out of the query + * @param string selectorState + * One of "pseudo", "id", "tag", "class", "null" + */ + getSuggestionsForQuery: function (query, completing, selectorState) { + let sugs = { + classes: new Map(), + tags: new Map(), + ids: new Map() + }; + let result = []; + let nodes = null; + // Filtering and sorting the results so that protocol transfer is miminal. + switch (selectorState) { + case "pseudo": + result = PSEUDO_SELECTORS.filter(item => { + return item[0].startsWith(":" + completing); + }); + break; + + case "class": + if (!query) { + nodes = this._multiFrameQuerySelectorAll("[class]"); + } else { + nodes = this._multiFrameQuerySelectorAll(query); + } + for (let node of nodes) { + for (let className of node.classList) { + sugs.classes.set(className, (sugs.classes.get(className)|0) + 1); + } + } + sugs.classes.delete(""); + sugs.classes.delete(HIDDEN_CLASS); + for (let [className, count] of sugs.classes) { + if (className.startsWith(completing)) { + result.push(["." + CSS.escape(className), count, selectorState]); + } + } + break; + + case "id": + if (!query) { + nodes = this._multiFrameQuerySelectorAll("[id]"); + } else { + nodes = this._multiFrameQuerySelectorAll(query); + } + for (let node of nodes) { + sugs.ids.set(node.id, (sugs.ids.get(node.id)|0) + 1); + } + for (let [id, count] of sugs.ids) { + if (id.startsWith(completing) && id !== "") { + result.push(["#" + CSS.escape(id), count, selectorState]); + } + } + break; + + case "tag": + if (!query) { + nodes = this._multiFrameQuerySelectorAll("*"); + } else { + nodes = this._multiFrameQuerySelectorAll(query); + } + for (let node of nodes) { + let tag = node.localName; + sugs.tags.set(tag, (sugs.tags.get(tag)|0) + 1); + } + for (let [tag, count] of sugs.tags) { + if ((new RegExp("^" + completing + ".*", "i")).test(tag)) { + result.push([tag, count, selectorState]); + } + } + + // For state 'tag' (no preceding # or .) and when there's no query (i.e. + // only one word) then search for the matching classes and ids + if (!query) { + result = [ + ...result, + ...this.getSuggestionsForQuery(null, completing, "class") + .suggestions, + ...this.getSuggestionsForQuery(null, completing, "id") + .suggestions + ]; + } + + break; + + case "null": + nodes = this._multiFrameQuerySelectorAll(query); + for (let node of nodes) { + sugs.ids.set(node.id, (sugs.ids.get(node.id)|0) + 1); + let tag = node.localName; + sugs.tags.set(tag, (sugs.tags.get(tag)|0) + 1); + for (let className of node.classList) { + sugs.classes.set(className, (sugs.classes.get(className)|0) + 1); + } + } + for (let [tag, count] of sugs.tags) { + tag && result.push([tag, count]); + } + for (let [id, count] of sugs.ids) { + id && result.push(["#" + id, count]); + } + sugs.classes.delete(""); + sugs.classes.delete(HIDDEN_CLASS); + for (let [className, count] of sugs.classes) { + className && result.push(["." + className, count]); + } + } + + // Sort by count (desc) and name (asc) + result = result.sort((a, b) => { + // Computed a sortable string with first the inverted count, then the name + let sortA = (10000 - a[1]) + a[0]; + let sortB = (10000 - b[1]) + b[0]; + + // Prefixing ids, classes and tags, to group results + let firstA = a[0].substring(0, 1); + let firstB = b[0].substring(0, 1); + + if (firstA === "#") { + sortA = "2" + sortA; + } else if (firstA === ".") { + sortA = "1" + sortA; + } else { + sortA = "0" + sortA; + } + + if (firstB === "#") { + sortB = "2" + sortB; + } else if (firstB === ".") { + sortB = "1" + sortB; + } else { + sortB = "0" + sortB; + } + + // String compare + return sortA.localeCompare(sortB); + }); + + result.slice(0, 25); + + return { + query: query, + suggestions: result + }; + }, + + /** + * Add a pseudo-class lock to a node. + * + * @param NodeActor node + * @param string pseudo + * A pseudoclass: ':hover', ':active', ':focus' + * @param options + * Options object: + * `parents`: True if the pseudo-class should be added + * to parent nodes. + * + * @returns An empty packet. A "pseudoClassLock" mutation will + * be queued for any changed nodes. + */ + addPseudoClassLock: function (node, pseudo, options = {}) { + if (isNodeDead(node)) { + return; + } + + // There can be only one node locked per pseudo, so dismiss all existing + // ones + for (let locked of this._activePseudoClassLocks) { + if (DOMUtils.hasPseudoClassLock(locked.rawNode, pseudo)) { + this._removePseudoClassLock(locked, pseudo); + } + } + + this._addPseudoClassLock(node, pseudo); + + if (!options.parents) { + return; + } + + let walker = this.getDocumentWalker(node.rawNode); + let cur; + while ((cur = walker.parentNode())) { + let curNode = this._ref(cur); + this._addPseudoClassLock(curNode, pseudo); + } + }, + + _queuePseudoClassMutation: function (node) { + this.queueMutation({ + target: node.actorID, + type: "pseudoClassLock", + pseudoClassLocks: node.writePseudoClassLocks() + }); + }, + + _addPseudoClassLock: function (node, pseudo) { + if (node.rawNode.nodeType !== Ci.nsIDOMNode.ELEMENT_NODE) { + return false; + } + DOMUtils.addPseudoClassLock(node.rawNode, pseudo); + this._activePseudoClassLocks.add(node); + this._queuePseudoClassMutation(node); + return true; + }, + + _installHelperSheet: function (node) { + if (!this.installedHelpers) { + this.installedHelpers = new WeakMap(); + } + let win = node.rawNode.ownerDocument.defaultView; + if (!this.installedHelpers.has(win)) { + let { Style } = require("sdk/stylesheet/style"); + let { attach } = require("sdk/content/mod"); + let style = Style({source: HELPER_SHEET, type: "agent" }); + attach(style, win); + this.installedHelpers.set(win, style); + } + }, + + hideNode: function (node) { + if (isNodeDead(node)) { + return; + } + + this._installHelperSheet(node); + node.rawNode.classList.add(HIDDEN_CLASS); + }, + + unhideNode: function (node) { + if (isNodeDead(node)) { + return; + } + + node.rawNode.classList.remove(HIDDEN_CLASS); + }, + + /** + * Remove a pseudo-class lock from a node. + * + * @param NodeActor node + * @param string pseudo + * A pseudoclass: ':hover', ':active', ':focus' + * @param options + * Options object: + * `parents`: True if the pseudo-class should be removed + * from parent nodes. + * + * @returns An empty response. "pseudoClassLock" mutations + * will be emitted for any changed nodes. + */ + removePseudoClassLock: function (node, pseudo, options = {}) { + if (isNodeDead(node)) { + return; + } + + this._removePseudoClassLock(node, pseudo); + + // Remove pseudo class for children as we don't want to allow + // turning it on for some childs without setting it on some parents + for (let locked of this._activePseudoClassLocks) { + if (node.rawNode.contains(locked.rawNode) && + DOMUtils.hasPseudoClassLock(locked.rawNode, pseudo)) { + this._removePseudoClassLock(locked, pseudo); + } + } + + if (!options.parents) { + return; + } + + let walker = this.getDocumentWalker(node.rawNode); + let cur; + while ((cur = walker.parentNode())) { + let curNode = this._ref(cur); + this._removePseudoClassLock(curNode, pseudo); + } + }, + + _removePseudoClassLock: function (node, pseudo) { + if (node.rawNode.nodeType != Ci.nsIDOMNode.ELEMENT_NODE) { + return false; + } + DOMUtils.removePseudoClassLock(node.rawNode, pseudo); + if (!node.writePseudoClassLocks()) { + this._activePseudoClassLocks.delete(node); + } + + this._queuePseudoClassMutation(node); + return true; + }, + + /** + * Clear all the pseudo-classes on a given node or all nodes. + * @param {NodeActor} node Optional node to clear pseudo-classes on + */ + clearPseudoClassLocks: function (node) { + if (node && isNodeDead(node)) { + return; + } + + if (node) { + DOMUtils.clearPseudoClassLocks(node.rawNode); + this._activePseudoClassLocks.delete(node); + this._queuePseudoClassMutation(node); + } else { + for (let locked of this._activePseudoClassLocks) { + DOMUtils.clearPseudoClassLocks(locked.rawNode); + this._activePseudoClassLocks.delete(locked); + this._queuePseudoClassMutation(locked); + } + } + }, + + /** + * Get a node's innerHTML property. + */ + innerHTML: function (node) { + let html = ""; + if (!isNodeDead(node)) { + html = node.rawNode.innerHTML; + } + return LongStringActor(this.conn, html); + }, + + /** + * Set a node's innerHTML property. + * + * @param {NodeActor} node The node. + * @param {string} value The piece of HTML content. + */ + setInnerHTML: function (node, value) { + if (isNodeDead(node)) { + return; + } + + let rawNode = node.rawNode; + if (rawNode.nodeType !== rawNode.ownerDocument.ELEMENT_NODE) { + throw new Error("Can only change innerHTML to element nodes"); + } + rawNode.innerHTML = value; + }, + + /** + * Get a node's outerHTML property. + * + * @param {NodeActor} node The node. + */ + outerHTML: function (node) { + let outerHTML = ""; + if (!isNodeDead(node)) { + outerHTML = node.rawNode.outerHTML; + } + return LongStringActor(this.conn, outerHTML); + }, + + /** + * Set a node's outerHTML property. + * + * @param {NodeActor} node The node. + * @param {string} value The piece of HTML content. + */ + setOuterHTML: function (node, value) { + if (isNodeDead(node)) { + return; + } + + let parsedDOM = DOMParser.parseFromString(value, "text/html"); + let rawNode = node.rawNode; + let parentNode = rawNode.parentNode; + + // Special case for head and body. Setting document.body.outerHTML + // creates an extra <head> tag, and document.head.outerHTML creates + // an extra <body>. So instead we will call replaceChild with the + // parsed DOM, assuming that they aren't trying to set both tags at once. + if (rawNode.tagName === "BODY") { + if (parsedDOM.head.innerHTML === "") { + parentNode.replaceChild(parsedDOM.body, rawNode); + } else { + rawNode.outerHTML = value; + } + } else if (rawNode.tagName === "HEAD") { + if (parsedDOM.body.innerHTML === "") { + parentNode.replaceChild(parsedDOM.head, rawNode); + } else { + rawNode.outerHTML = value; + } + } else if (node.isDocumentElement()) { + // Unable to set outerHTML on the document element. Fall back by + // setting attributes manually, then replace the body and head elements. + let finalAttributeModifications = []; + let attributeModifications = {}; + for (let attribute of rawNode.attributes) { + attributeModifications[attribute.name] = null; + } + for (let attribute of parsedDOM.documentElement.attributes) { + attributeModifications[attribute.name] = attribute.value; + } + for (let key in attributeModifications) { + finalAttributeModifications.push({ + attributeName: key, + newValue: attributeModifications[key] + }); + } + node.modifyAttributes(finalAttributeModifications); + rawNode.replaceChild(parsedDOM.head, rawNode.querySelector("head")); + rawNode.replaceChild(parsedDOM.body, rawNode.querySelector("body")); + } else { + rawNode.outerHTML = value; + } + }, + + /** + * Insert adjacent HTML to a node. + * + * @param {Node} node + * @param {string} position One of "beforeBegin", "afterBegin", "beforeEnd", + * "afterEnd" (see Element.insertAdjacentHTML). + * @param {string} value The HTML content. + */ + insertAdjacentHTML: function (node, position, value) { + if (isNodeDead(node)) { + return {node: [], newParents: []}; + } + + let rawNode = node.rawNode; + let isInsertAsSibling = position === "beforeBegin" || + position === "afterEnd"; + + // Don't insert anything adjacent to the document element. + if (isInsertAsSibling && node.isDocumentElement()) { + throw new Error("Can't insert adjacent element to the root."); + } + + let rawParentNode = rawNode.parentNode; + if (!rawParentNode && isInsertAsSibling) { + throw new Error("Can't insert as sibling without parent node."); + } + + // We can't use insertAdjacentHTML, because we want to return the nodes + // being created (so the front can remove them if the user undoes + // the change). So instead, use Range.createContextualFragment(). + let range = rawNode.ownerDocument.createRange(); + if (position === "beforeBegin" || position === "afterEnd") { + range.selectNode(rawNode); + } else { + range.selectNodeContents(rawNode); + } + let docFrag = range.createContextualFragment(value); + let newRawNodes = Array.from(docFrag.childNodes); + switch (position) { + case "beforeBegin": + rawParentNode.insertBefore(docFrag, rawNode); + break; + case "afterEnd": + // Note: if the second argument is null, rawParentNode.insertBefore + // behaves like rawParentNode.appendChild. + rawParentNode.insertBefore(docFrag, rawNode.nextSibling); + break; + case "afterBegin": + rawNode.insertBefore(docFrag, rawNode.firstChild); + break; + case "beforeEnd": + rawNode.appendChild(docFrag); + break; + default: + throw new Error("Invalid position value. Must be either " + + "'beforeBegin', 'beforeEnd', 'afterBegin' or 'afterEnd'."); + } + + return this.attachElements(newRawNodes); + }, + + /** + * Duplicate a specified node + * + * @param {NodeActor} node The node to duplicate. + */ + duplicateNode: function ({rawNode}) { + let clonedNode = rawNode.cloneNode(true); + rawNode.parentNode.insertBefore(clonedNode, rawNode.nextSibling); + }, + + /** + * Test whether a node is a document or a document element. + * + * @param {NodeActor} node The node to remove. + * @return {boolean} True if the node is a document or a document element. + */ + isDocumentOrDocumentElementNode: function (node) { + return ((node.rawNode.ownerDocument && + node.rawNode.ownerDocument.documentElement === this.rawNode) || + node.rawNode.nodeType === Ci.nsIDOMNode.DOCUMENT_NODE); + }, + + /** + * Removes a node from its parent node. + * + * @param {NodeActor} node The node to remove. + * @returns The node's nextSibling before it was removed. + */ + removeNode: function (node) { + if (isNodeDead(node) || this.isDocumentOrDocumentElementNode(node)) { + throw Error("Cannot remove document, document elements or dead nodes."); + } + + let nextSibling = this.nextSibling(node); + node.rawNode.remove(); + // Mutation events will take care of the rest. + return nextSibling; + }, + + /** + * Removes an array of nodes from their parent node. + * + * @param {NodeActor[]} nodes The nodes to remove. + */ + removeNodes: function (nodes) { + // Check that all nodes are valid before processing the removals. + for (let node of nodes) { + if (isNodeDead(node) || this.isDocumentOrDocumentElementNode(node)) { + throw Error("Cannot remove document, document elements or dead nodes"); + } + } + + for (let node of nodes) { + node.rawNode.remove(); + // Mutation events will take care of the rest. + } + }, + + /** + * Insert a node into the DOM. + */ + insertBefore: function (node, parent, sibling) { + if (isNodeDead(node) || + isNodeDead(parent) || + (sibling && isNodeDead(sibling))) { + return; + } + + let rawNode = node.rawNode; + let rawParent = parent.rawNode; + let rawSibling = sibling ? sibling.rawNode : null; + + // Don't bother inserting a node if the document position isn't going + // to change. This prevents needless iframes reloading and mutations. + if (rawNode.parentNode === rawParent) { + let currentNextSibling = this.nextSibling(node); + currentNextSibling = currentNextSibling ? currentNextSibling.rawNode : + null; + + if (rawNode === rawSibling || currentNextSibling === rawSibling) { + return; + } + } + + rawParent.insertBefore(rawNode, rawSibling); + }, + + /** + * Editing a node's tagname actually means creating a new node with the same + * attributes, removing the node and inserting the new one instead. + * This method does not return anything as mutation events are taking care of + * informing the consumers about changes. + */ + editTagName: function (node, tagName) { + if (isNodeDead(node)) { + return null; + } + + let oldNode = node.rawNode; + + // Create a new element with the same attributes as the current element and + // prepare to replace the current node with it. + let newNode; + try { + newNode = nodeDocument(oldNode).createElement(tagName); + } catch (x) { + // Failed to create a new element with that tag name, ignore the change, + // and signal the error to the front. + return Promise.reject(new Error("Could not change node's tagName to " + tagName)); + } + + let attrs = oldNode.attributes; + for (let i = 0; i < attrs.length; i++) { + newNode.setAttribute(attrs[i].name, attrs[i].value); + } + + // Insert the new node, and transfer the old node's children. + oldNode.parentNode.insertBefore(newNode, oldNode); + while (oldNode.firstChild) { + newNode.appendChild(oldNode.firstChild); + } + + oldNode.remove(); + return null; + }, + + /** + * Get any pending mutation records. Must be called by the client after + * the `new-mutations` notification is received. Returns an array of + * mutation records. + * + * Mutation records have a basic structure: + * + * { + * type: attributes|characterData|childList, + * target: <domnode actor ID>, + * } + * + * And additional attributes based on the mutation type: + * + * `attributes` type: + * attributeName: <string> - the attribute that changed + * attributeNamespace: <string> - the attribute's namespace URI, if any. + * newValue: <string> - The new value of the attribute, if any. + * + * `characterData` type: + * newValue: <string> - the new nodeValue for the node + * + * `childList` type is returned when the set of children for a node + * has changed. Includes extra data, which can be used by the client to + * maintain its ownership subtree. + * + * added: array of <domnode actor ID> - The list of actors *previously + * seen by the client* that were added to the target node. + * removed: array of <domnode actor ID> The list of actors *previously + * seen by the client* that were removed from the target node. + * inlineTextChild: If the node now has a single text child, it will + * be sent here. + * + * Actors that are included in a MutationRecord's `removed` but + * not in an `added` have been removed from the client's ownership + * tree (either by being moved under a node the client has seen yet + * or by being removed from the tree entirely), and is considered + * 'orphaned'. + * + * Keep in mind that if a node that the client hasn't seen is moved + * into or out of the target node, it will not be included in the + * removedNodes and addedNodes list, so if the client is interested + * in the new set of children it needs to issue a `children` request. + */ + getMutations: function (options = {}) { + let pending = this._pendingMutations || []; + this._pendingMutations = []; + + if (options.cleanup) { + for (let node of this._orphaned) { + // Release the orphaned node. Nodes or children that have been + // retained will be moved to this._retainedOrphans. + this.releaseNode(node); + } + this._orphaned = new Set(); + } + + return pending; + }, + + queueMutation: function (mutation) { + if (!this.actorID || this._destroyed) { + // We've been destroyed, don't bother queueing this mutation. + return; + } + // We only send the `new-mutations` notification once, until the client + // fetches mutations with the `getMutations` packet. + let needEvent = this._pendingMutations.length === 0; + + this._pendingMutations.push(mutation); + + if (needEvent) { + events.emit(this, "new-mutations"); + } + }, + + /** + * Handles mutations from the DOM mutation observer API. + * + * @param array[MutationRecord] mutations + * See https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver#MutationRecord + */ + onMutations: function (mutations) { + // Notify any observers that want *all* mutations (even on nodes that aren't + // referenced). This is not sent over the protocol so can only be used by + // scripts running in the server process. + events.emit(this, "any-mutation"); + + for (let change of mutations) { + let targetActor = this.getNode(change.target); + if (!targetActor) { + continue; + } + let targetNode = change.target; + let type = change.type; + let mutation = { + type: type, + target: targetActor.actorID, + }; + + if (type === "attributes") { + mutation.attributeName = change.attributeName; + mutation.attributeNamespace = change.attributeNamespace || undefined; + mutation.newValue = targetNode.hasAttribute(mutation.attributeName) ? + targetNode.getAttribute(mutation.attributeName) + : null; + } else if (type === "characterData") { + mutation.newValue = targetNode.nodeValue; + this._maybeQueueInlineTextChildMutation(change, targetNode); + } else if (type === "childList" || type === "nativeAnonymousChildList") { + // Get the list of removed and added actors that the client has seen + // so that it can keep its ownership tree up to date. + let removedActors = []; + let addedActors = []; + for (let removed of change.removedNodes) { + let removedActor = this.getNode(removed); + if (!removedActor) { + // If the client never encountered this actor we don't need to + // mention that it was removed. + continue; + } + // While removed from the tree, nodes are saved as orphaned. + this._orphaned.add(removedActor); + removedActors.push(removedActor.actorID); + } + for (let added of change.addedNodes) { + let addedActor = this.getNode(added); + if (!addedActor) { + // If the client never encounted this actor we don't need to tell + // it about its addition for ownership tree purposes - if the + // client wants to see the new nodes it can ask for children. + continue; + } + // The actor is reconnected to the ownership tree, unorphan + // it and let the client know so that its ownership tree is up + // to date. + this._orphaned.delete(addedActor); + addedActors.push(addedActor.actorID); + } + + mutation.numChildren = targetActor.numChildren; + mutation.removed = removedActors; + mutation.added = addedActors; + + let inlineTextChild = this.inlineTextChild(targetActor); + if (inlineTextChild) { + mutation.inlineTextChild = inlineTextChild.form(); + } + } + this.queueMutation(mutation); + } + }, + + /** + * Check if the provided mutation could change the way the target element is + * inlined with its parent node. If it might, a custom mutation of type + * "inlineTextChild" will be queued. + * + * @param {MutationRecord} mutation + * A characterData type mutation + */ + _maybeQueueInlineTextChildMutation: function (mutation) { + let {oldValue, target} = mutation; + let newValue = target.nodeValue; + let limit = gValueSummaryLength; + + if ((oldValue.length <= limit && newValue.length <= limit) || + (oldValue.length > limit && newValue.length > limit)) { + // Bail out if the new & old values are both below/above the size limit. + return; + } + + let parentActor = this.getNode(target.parentNode); + if (!parentActor || parentActor.rawNode.children.length > 0) { + // If the parent node has other children, a character data mutation will + // not change anything regarding inlining text nodes. + return; + } + + let inlineTextChild = this.inlineTextChild(parentActor); + this.queueMutation({ + type: "inlineTextChild", + target: parentActor.actorID, + inlineTextChild: + inlineTextChild ? inlineTextChild.form() : undefined + }); + }, + + onFrameLoad: function ({ window, isTopLevel }) { + if (isTopLevel) { + // If we initialize the inspector while the document is loading, + // we may already have a root document set in the constructor. + if (this.rootDoc && !Cu.isDeadWrapper(this.rootDoc) && + this.rootDoc.defaultView) { + this.onFrameUnload({ window: this.rootDoc.defaultView }); + } + this.rootDoc = window.document; + this.rootNode = this.document(); + this.queueMutation({ + type: "newRoot", + target: this.rootNode.form() + }); + return; + } + let frame = getFrameElement(window); + let frameActor = this.getNode(frame); + if (!frameActor) { + return; + } + + this.queueMutation({ + type: "frameLoad", + target: frameActor.actorID, + }); + + // Send a childList mutation on the frame. + this.queueMutation({ + type: "childList", + target: frameActor.actorID, + added: [], + removed: [] + }); + }, + + // Returns true if domNode is in window or a subframe. + _childOfWindow: function (window, domNode) { + let win = nodeDocument(domNode).defaultView; + while (win) { + if (win === window) { + return true; + } + win = getFrameElement(win); + } + return false; + }, + + onFrameUnload: function ({ window }) { + // Any retained orphans that belong to this document + // or its children need to be released, and a mutation sent + // to notify of that. + let releasedOrphans = []; + + for (let retained of this._retainedOrphans) { + if (Cu.isDeadWrapper(retained.rawNode) || + this._childOfWindow(window, retained.rawNode)) { + this._retainedOrphans.delete(retained); + releasedOrphans.push(retained.actorID); + this.releaseNode(retained, { force: true }); + } + } + + if (releasedOrphans.length > 0) { + this.queueMutation({ + target: this.rootNode.actorID, + type: "unretained", + nodes: releasedOrphans + }); + } + + let doc = window.document; + let documentActor = this.getNode(doc); + if (!documentActor) { + return; + } + + if (this.rootDoc === doc) { + this.rootDoc = null; + this.rootNode = null; + } + + this.queueMutation({ + type: "documentUnload", + target: documentActor.actorID + }); + + let walker = this.getDocumentWalker(doc); + let parentNode = walker.parentNode(); + if (parentNode) { + // Send a childList mutation on the frame so that clients know + // they should reread the children list. + this.queueMutation({ + type: "childList", + target: this.getNode(parentNode).actorID, + added: [], + removed: [] + }); + } + + // Need to force a release of this node, because those nodes can't + // be accessed anymore. + this.releaseNode(documentActor, { force: true }); + }, + + /** + * Check if a node is attached to the DOM tree of the current page. + * @param {nsIDomNode} rawNode + * @return {Boolean} false if the node is removed from the tree or within a + * document fragment + */ + _isInDOMTree: function (rawNode) { + let walker = this.getDocumentWalker(rawNode); + let current = walker.currentNode; + + // Reaching the top of tree + while (walker.parentNode()) { + current = walker.currentNode; + } + + // The top of the tree is a fragment or is not rootDoc, hence rawNode isn't + // attached + if (current.nodeType === Ci.nsIDOMNode.DOCUMENT_FRAGMENT_NODE || + current !== this.rootDoc) { + return false; + } + + // Otherwise the top of the tree is rootDoc, hence rawNode is in rootDoc + return true; + }, + + /** + * @see _isInDomTree + */ + isInDOMTree: function (node) { + if (isNodeDead(node)) { + return false; + } + return this._isInDOMTree(node.rawNode); + }, + + /** + * Given an ObjectActor (identified by its ID), commonly used in the debugger, + * webconsole and variablesView, return the corresponding inspector's + * NodeActor + */ + getNodeActorFromObjectActor: function (objectActorID) { + let actor = this.conn.getActor(objectActorID); + if (!actor) { + return null; + } + + let debuggerObject = this.conn.getActor(objectActorID).obj; + let rawNode = debuggerObject.unsafeDereference(); + + if (!this._isInDOMTree(rawNode)) { + return null; + } + + // This is a special case for the document object whereby it is considered + // as document.documentElement (the <html> node) + if (rawNode.defaultView && rawNode === rawNode.defaultView.document) { + rawNode = rawNode.documentElement; + } + + return this.attachElement(rawNode); + }, + + /** + * Given a StyleSheetActor (identified by its ID), commonly used in the + * style-editor, get its ownerNode and return the corresponding walker's + * NodeActor. + * Note that getNodeFromActor was added later and can now be used instead. + */ + getStyleSheetOwnerNode: function (styleSheetActorID) { + return this.getNodeFromActor(styleSheetActorID, ["ownerNode"]); + }, + + /** + * This method can be used to retrieve NodeActor for DOM nodes from other + * actors in a way that they can later be highlighted in the page, or + * selected in the inspector. + * If an actor has a reference to a DOM node, and the UI needs to know about + * this DOM node (and possibly select it in the inspector), the UI should + * first retrieve a reference to the walkerFront: + * + * // Make sure the inspector/walker have been initialized first. + * toolbox.initInspector().then(() => { + * // Retrieve the walker. + * let walker = toolbox.walker; + * }); + * + * And then call this method: + * + * // Get the nodeFront from my actor, passing the ID and properties path. + * walker.getNodeFromActor(myActorID, ["element"]).then(nodeFront => { + * // Use the nodeFront, e.g. select the node in the inspector. + * toolbox.getPanel("inspector").selection.setNodeFront(nodeFront); + * }); + * + * @param {String} actorID The ID for the actor that has a reference to the + * DOM node. + * @param {Array} path Where, on the actor, is the DOM node stored. If in the + * scope of the actor, the node is available as `this.data.node`, then this + * should be ["data", "node"]. + * @return {NodeActor} The attached NodeActor, or null if it couldn't be + * found. + */ + getNodeFromActor: function (actorID, path) { + let actor = this.conn.getActor(actorID); + if (!actor) { + return null; + } + + let obj = actor; + for (let name of path) { + if (!(name in obj)) { + return null; + } + obj = obj[name]; + } + + return this.attachElement(obj); + }, + + /** + * Returns an instance of the LayoutActor that is used to retrieve CSS layout-related + * information. + * + * @return {LayoutActor} + */ + getLayoutInspector: function () { + if (!this.layoutActor) { + this.layoutActor = new LayoutActor(this.conn, this.tabActor, this); + } + + return this.layoutActor; + }, +}); + +/** + * Server side of the inspector actor, which is used to create + * inspector-related actors, including the walker. + */ +exports.InspectorActor = protocol.ActorClassWithSpec(inspectorSpec, { + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + + this._onColorPicked = this._onColorPicked.bind(this); + this._onColorPickCanceled = this._onColorPickCanceled.bind(this); + this.destroyEyeDropper = this.destroyEyeDropper.bind(this); + }, + + destroy: function () { + protocol.Actor.prototype.destroy.call(this); + + this.destroyEyeDropper(); + + this._highlighterPromise = null; + this._pageStylePromise = null; + this._walkerPromise = null; + this.walker = null; + this.tabActor = null; + }, + + // Forces destruction of the actor and all its children + // like highlighter, walker and style actors. + disconnect: function () { + this.destroy(); + }, + + get window() { + return this.tabActor.window; + }, + + getWalker: function (options = {}) { + if (this._walkerPromise) { + return this._walkerPromise; + } + + let deferred = promise.defer(); + this._walkerPromise = deferred.promise; + + let window = this.window; + let domReady = () => { + let tabActor = this.tabActor; + window.removeEventListener("DOMContentLoaded", domReady, true); + this.walker = WalkerActor(this.conn, tabActor, options); + this.manage(this.walker); + events.once(this.walker, "destroyed", () => { + this._walkerPromise = null; + this._pageStylePromise = null; + }); + deferred.resolve(this.walker); + }; + + if (window.document.readyState === "loading") { + window.addEventListener("DOMContentLoaded", domReady, true); + } else { + domReady(); + } + + return this._walkerPromise; + }, + + getPageStyle: function () { + if (this._pageStylePromise) { + return this._pageStylePromise; + } + + this._pageStylePromise = this.getWalker().then(walker => { + let pageStyle = PageStyleActor(this); + this.manage(pageStyle); + return pageStyle; + }); + return this._pageStylePromise; + }, + + /** + * The most used highlighter actor is the HighlighterActor which can be + * conveniently retrieved by this method. + * The same instance will always be returned by this method when called + * several times. + * The highlighter actor returned here is used to highlighter elements's + * box-models from the markup-view, box model, console, debugger, ... as + * well as select elements with the pointer (pick). + * + * @param {Boolean} autohide Optionally autohide the highlighter after an + * element has been picked + * @return {HighlighterActor} + */ + getHighlighter: function (autohide) { + if (this._highlighterPromise) { + return this._highlighterPromise; + } + + this._highlighterPromise = this.getWalker().then(walker => { + let highlighter = HighlighterActor(this, autohide); + this.manage(highlighter); + return highlighter; + }); + return this._highlighterPromise; + }, + + /** + * If consumers need to display several highlighters at the same time or + * different types of highlighters, then this method should be used, passing + * the type name of the highlighter needed as argument. + * A new instance will be created everytime the method is called, so it's up + * to the consumer to release it when it is not needed anymore + * + * @param {String} type The type of highlighter to create + * @return {Highlighter} The highlighter actor instance or null if the + * typeName passed doesn't match any available highlighter + */ + getHighlighterByType: function (typeName) { + if (isTypeRegistered(typeName)) { + return CustomHighlighterActor(this, typeName); + } + return null; + }, + + /** + * Get the node's image data if any (for canvas and img nodes). + * Returns an imageData object with the actual data being a LongStringActor + * and a size json object. + * The image data is transmitted as a base64 encoded png data-uri. + * The method rejects if the node isn't an image or if the image is missing + * + * Accepts a maxDim request parameter to resize images that are larger. This + * is important as the resizing occurs server-side so that image-data being + * transfered in the longstring back to the client will be that much smaller + */ + getImageDataFromURL: function (url, maxDim) { + let img = new this.window.Image(); + img.src = url; + + // imageToImageData waits for the image to load. + return imageToImageData(img, maxDim).then(imageData => { + return { + data: LongStringActor(this.conn, imageData.data), + size: imageData.size + }; + }); + }, + + /** + * Resolve a URL to its absolute form, in the scope of a given content window. + * @param {String} url. + * @param {NodeActor} node If provided, the owner window of this node will be + * used to resolve the URL. Otherwise, the top-level content window will be + * used instead. + * @return {String} url. + */ + resolveRelativeURL: function (url, node) { + let document = isNodeDead(node) + ? this.window.document + : nodeDocument(node.rawNode); + + if (!document) { + return url; + } + + let baseURI = Services.io.newURI(document.location.href, null, null); + return Services.io.newURI(url, null, baseURI).spec; + }, + + /** + * Create an instance of the eye-dropper highlighter and store it on this._eyeDropper. + * Note that for now, a new instance is created every time to deal with page navigation. + */ + createEyeDropper: function () { + this.destroyEyeDropper(); + this._highlighterEnv = new HighlighterEnvironment(); + this._highlighterEnv.initFromTabActor(this.tabActor); + this._eyeDropper = new EyeDropper(this._highlighterEnv); + }, + + /** + * Destroy the current eye-dropper highlighter instance. + */ + destroyEyeDropper: function () { + if (this._eyeDropper) { + this.cancelPickColorFromPage(); + this._eyeDropper.destroy(); + this._eyeDropper = null; + this._highlighterEnv.destroy(); + this._highlighterEnv = null; + } + }, + + /** + * Pick a color from the page using the eye-dropper. This method doesn't return anything + * but will cause events to be sent to the front when a color is picked or when the user + * cancels the picker. + * @param {Object} options + */ + pickColorFromPage: function (options) { + this.createEyeDropper(); + this._eyeDropper.show(this.window.document.documentElement, options); + this._eyeDropper.once("selected", this._onColorPicked); + this._eyeDropper.once("canceled", this._onColorPickCanceled); + events.once(this.tabActor, "will-navigate", this.destroyEyeDropper); + }, + + /** + * After the pickColorFromPage method is called, the only way to dismiss the eye-dropper + * highlighter is for the user to click in the page and select a color. If you need to + * dismiss the eye-dropper programatically instead, use this method. + */ + cancelPickColorFromPage: function () { + if (this._eyeDropper) { + this._eyeDropper.hide(); + this._eyeDropper.off("selected", this._onColorPicked); + this._eyeDropper.off("canceled", this._onColorPickCanceled); + events.off(this.tabActor, "will-navigate", this.destroyEyeDropper); + } + }, + + _onColorPicked: function (e, color) { + events.emit(this, "color-picked", color); + }, + + _onColorPickCanceled: function () { + events.emit(this, "color-pick-canceled"); + } +}); + +// Exported for test purposes. +exports._documentWalker = DocumentWalker; + +function nodeDocument(node) { + if (Cu.isDeadWrapper(node)) { + return null; + } + return node.ownerDocument || + (node.nodeType == Ci.nsIDOMNode.DOCUMENT_NODE ? node : null); +} + +function nodeDocshell(node) { + let doc = node ? nodeDocument(node) : null; + let win = doc ? doc.defaultView : null; + if (win) { + return win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell); + } + return null; +} + +function isNodeDead(node) { + return !node || !node.rawNode || Cu.isDeadWrapper(node.rawNode); +} + +/** + * Wrapper for inDeepTreeWalker. Adds filtering to the traversal methods. + * See inDeepTreeWalker for more information about the methods. + * + * @param {DOMNode} node + * @param {Window} rootWin + * @param {Int} whatToShow See nodeFilterConstants / inIDeepTreeWalker for + * options. + * @param {Function} filter A custom filter function Taking in a DOMNode + * and returning an Int. See WalkerActor.nodeFilter for an example. + */ +function DocumentWalker(node, rootWin, + whatToShow = nodeFilterConstants.SHOW_ALL, + filter = standardTreeWalkerFilter) { + if (!rootWin.location) { + throw new Error("Got an invalid root window in DocumentWalker"); + } + + this.walker = Cc["@mozilla.org/inspector/deep-tree-walker;1"] + .createInstance(Ci.inIDeepTreeWalker); + this.walker.showAnonymousContent = true; + this.walker.showSubDocuments = true; + this.walker.showDocumentsAsNodes = true; + this.walker.init(rootWin.document, whatToShow); + this.filter = filter; + + // Make sure that the walker knows about the initial node (which could + // be skipped due to a filter). Note that simply calling parentNode() + // causes currentNode to be updated. + this.walker.currentNode = node; + while (node && + this.filter(node) === nodeFilterConstants.FILTER_SKIP) { + node = this.walker.parentNode(); + } +} + +DocumentWalker.prototype = { + get node() { + return this.walker.node; + }, + get whatToShow() { + return this.walker.whatToShow; + }, + get currentNode() { + return this.walker.currentNode; + }, + set currentNode(val) { + this.walker.currentNode = val; + }, + + parentNode: function () { + return this.walker.parentNode(); + }, + + nextNode: function () { + let node = this.walker.currentNode; + if (!node) { + return null; + } + + let nextNode = this.walker.nextNode(); + while (nextNode && + this.filter(nextNode) === nodeFilterConstants.FILTER_SKIP) { + nextNode = this.walker.nextNode(); + } + + return nextNode; + }, + + firstChild: function () { + let node = this.walker.currentNode; + if (!node) { + return null; + } + + let firstChild = this.walker.firstChild(); + while (firstChild && + this.filter(firstChild) === nodeFilterConstants.FILTER_SKIP) { + firstChild = this.walker.nextSibling(); + } + + return firstChild; + }, + + lastChild: function () { + let node = this.walker.currentNode; + if (!node) { + return null; + } + + let lastChild = this.walker.lastChild(); + while (lastChild && + this.filter(lastChild) === nodeFilterConstants.FILTER_SKIP) { + lastChild = this.walker.previousSibling(); + } + + return lastChild; + }, + + previousSibling: function () { + let node = this.walker.previousSibling(); + while (node && this.filter(node) === nodeFilterConstants.FILTER_SKIP) { + node = this.walker.previousSibling(); + } + return node; + }, + + nextSibling: function () { + let node = this.walker.nextSibling(); + while (node && this.filter(node) === nodeFilterConstants.FILTER_SKIP) { + node = this.walker.nextSibling(); + } + return node; + } +}; + +function isInXULDocument(el) { + let doc = nodeDocument(el); + return doc && + doc.documentElement && + doc.documentElement.namespaceURI === XUL_NS; +} + +/** + * This DeepTreeWalker filter skips whitespace text nodes and anonymous + * content with the exception of ::before and ::after and anonymous content + * in XUL document (needed to show all elements in the browser toolbox). + */ +function standardTreeWalkerFilter(node) { + // ::before and ::after are native anonymous content, but we always + // want to show them + if (node.nodeName === "_moz_generated_content_before" || + node.nodeName === "_moz_generated_content_after") { + return nodeFilterConstants.FILTER_ACCEPT; + } + + // Ignore empty whitespace text nodes that do not impact the layout. + if (isWhitespaceTextNode(node)) { + return nodeHasSize(node) + ? nodeFilterConstants.FILTER_ACCEPT + : nodeFilterConstants.FILTER_SKIP; + } + + // Ignore all native and XBL anonymous content inside a non-XUL document + if (!isInXULDocument(node) && (isXBLAnonymous(node) || + isNativeAnonymous(node))) { + // Note: this will skip inspecting the contents of feedSubscribeLine since + // that's XUL content injected in an HTML document, but we need to because + // this also skips many other elements that need to be skipped - like form + // controls, scrollbars, video controls, etc (see bug 1187482). + return nodeFilterConstants.FILTER_SKIP; + } + + return nodeFilterConstants.FILTER_ACCEPT; +} + +/** + * This DeepTreeWalker filter is like standardTreeWalkerFilter except that + * it also includes all anonymous content (like internal form controls). + */ +function allAnonymousContentTreeWalkerFilter(node) { + // Ignore empty whitespace text nodes that do not impact the layout. + if (isWhitespaceTextNode(node)) { + return nodeHasSize(node) + ? nodeFilterConstants.FILTER_ACCEPT + : nodeFilterConstants.FILTER_SKIP; + } + return nodeFilterConstants.FILTER_ACCEPT; +} + +/** + * Is the given node a text node composed of whitespace only? + * @param {DOMNode} node + * @return {Boolean} + */ +function isWhitespaceTextNode(node) { + return node.nodeType == Ci.nsIDOMNode.TEXT_NODE && !/[^\s]/.exec(node.nodeValue); +} + +/** + * Does the given node have non-0 width and height? + * @param {DOMNode} node + * @return {Boolean} + */ +function nodeHasSize(node) { + if (!node.getBoxQuads) { + return false; + } + + let quads = node.getBoxQuads(); + return quads.length && quads.some(quad => quad.bounds.width && quad.bounds.height); +} + +/** + * Returns a promise that is settled once the given HTMLImageElement has + * finished loading. + * + * @param {HTMLImageElement} image - The image element. + * @param {Number} timeout - Maximum amount of time the image is allowed to load + * before the waiting is aborted. Ignored if flags.testing is set. + * + * @return {Promise} that is fulfilled once the image has loaded. If the image + * fails to load or the load takes too long, the promise is rejected. + */ +function ensureImageLoaded(image, timeout) { + let { HTMLImageElement } = image.ownerDocument.defaultView; + if (!(image instanceof HTMLImageElement)) { + return promise.reject("image must be an HTMLImageELement"); + } + + if (image.complete) { + // The image has already finished loading. + return promise.resolve(); + } + + // This image is still loading. + let onLoad = AsyncUtils.listenOnce(image, "load"); + + // Reject if loading fails. + let onError = AsyncUtils.listenOnce(image, "error").then(() => { + return promise.reject("Image '" + image.src + "' failed to load."); + }); + + // Don't timeout when testing. This is never settled. + let onAbort = new Promise(() => {}); + + if (!flags.testing) { + // Tests are not running. Reject the promise after given timeout. + onAbort = DevToolsUtils.waitForTime(timeout).then(() => { + return promise.reject("Image '" + image.src + "' took too long to load."); + }); + } + + // See which happens first. + return promise.race([onLoad, onError, onAbort]); +} + +/** + * Given an <img> or <canvas> element, return the image data-uri. If @param node + * is an <img> element, the method waits a while for the image to load before + * the data is generated. If the image does not finish loading in a reasonable + * time (IMAGE_FETCHING_TIMEOUT milliseconds) the process aborts. + * + * @param {HTMLImageElement|HTMLCanvasElement} node - The <img> or <canvas> + * element, or Image() object. Other types cause the method to reject. + * @param {Number} maxDim - Optionally pass a maximum size you want the longest + * side of the image to be resized to before getting the image data. + + * @return {Promise} A promise that is fulfilled with an object containing the + * data-uri and size-related information: + * { data: "...", + * size: { + * naturalWidth: 400, + * naturalHeight: 300, + * resized: true } + * }. + * + * If something goes wrong, the promise is rejected. + */ +var imageToImageData = Task.async(function* (node, maxDim) { + let { HTMLCanvasElement, HTMLImageElement } = node.ownerDocument.defaultView; + + let isImg = node instanceof HTMLImageElement; + let isCanvas = node instanceof HTMLCanvasElement; + + if (!isImg && !isCanvas) { + throw new Error("node is not a <canvas> or <img> element."); + } + + if (isImg) { + // Ensure that the image is ready. + yield ensureImageLoaded(node, IMAGE_FETCHING_TIMEOUT); + } + + // Get the image resize ratio if a maxDim was provided + let resizeRatio = 1; + let imgWidth = node.naturalWidth || node.width; + let imgHeight = node.naturalHeight || node.height; + let imgMax = Math.max(imgWidth, imgHeight); + if (maxDim && imgMax > maxDim) { + resizeRatio = maxDim / imgMax; + } + + // Extract the image data + let imageData; + // The image may already be a data-uri, in which case, save ourselves the + // trouble of converting via the canvas.drawImage.toDataURL method, but only + // if the image doesn't need resizing + if (isImg && node.src.startsWith("data:") && resizeRatio === 1) { + imageData = node.src; + } else { + // Create a canvas to copy the rawNode into and get the imageData from + let canvas = node.ownerDocument.createElementNS(XHTML_NS, "canvas"); + canvas.width = imgWidth * resizeRatio; + canvas.height = imgHeight * resizeRatio; + let ctx = canvas.getContext("2d"); + + // Copy the rawNode image or canvas in the new canvas and extract data + ctx.drawImage(node, 0, 0, canvas.width, canvas.height); + imageData = canvas.toDataURL("image/png"); + } + + return { + data: imageData, + size: { + naturalWidth: imgWidth, + naturalHeight: imgHeight, + resized: resizeRatio !== 1 + } + }; +}); + +loader.lazyGetter(this, "DOMUtils", function () { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); +}); diff --git a/devtools/server/actors/layout.js b/devtools/server/actors/layout.js new file mode 100644 index 000000000..0b9242b5f --- /dev/null +++ b/devtools/server/actors/layout.js @@ -0,0 +1,131 @@ +/* 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 { Actor, ActorClassWithSpec } = require("devtools/shared/protocol"); +const { gridSpec, layoutSpec } = require("devtools/shared/specs/layout"); +const { getStringifiableFragments } = require("devtools/server/actors/utils/css-grid-utils"); + +/** + * Set of actors the expose the CSS layout information to the devtools protocol clients. + * + * The |Layout| actor is the main entry point. It is used to get various CSS + * layout-related information from the document. + * + * The |Grid| actor provides the grid fragment information to inspect the grid container. + */ + +/** + * The GridActor provides information about a given grid's fragment data. + */ +var GridActor = ActorClassWithSpec(gridSpec, { + /** + * @param {LayoutActor} layoutActor + * The LayoutActor instance. + * @param {DOMNode} containerEl + * The grid container element. + */ + initialize: function (layoutActor, containerEl) { + Actor.prototype.initialize.call(this, layoutActor.conn); + + this.containerEl = containerEl; + this.walker = layoutActor.walker; + }, + + destroy: function () { + Actor.prototype.destroy.call(this); + + this.containerEl = null; + this.gridFragments = null; + this.walker = null; + }, + + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + // Seralize the grid fragment data into JSON so protocol.js knows how to write + // and read the data. + let gridFragments = this.containerEl.getGridFragments(); + this.gridFragments = getStringifiableFragments(gridFragments); + + let form = { + actor: this.actorID, + gridFragments: this.gridFragments + }; + + return form; + }, +}); + +/** + * The CSS layout actor provides layout information for the given document. + */ +var LayoutActor = ActorClassWithSpec(layoutSpec, { + initialize: function (conn, tabActor, walker) { + Actor.prototype.initialize.call(this, conn); + + this.tabActor = tabActor; + this.walker = walker; + }, + + destroy: function () { + Actor.prototype.destroy.call(this); + + this.tabActor = null; + this.walker = null; + }, + + /** + * Returns an array of GridActor objects for all the grid containers found by iterating + * below the given rootNode. + * + * @param {Node|NodeActor} rootNode + * The root node to start iterating at. + * @return {Array} An array of GridActor objects. + */ + getGrids: function (rootNode) { + let grids = []; + + let treeWalker = this.walker.getDocumentWalker(rootNode); + while (treeWalker.nextNode()) { + let currentNode = treeWalker.currentNode; + + if (currentNode.getGridFragments && currentNode.getGridFragments().length > 0) { + let gridActor = new GridActor(this, currentNode); + grids.push(gridActor); + } + } + + return grids; + }, + + /** + * Returns an array of GridActor objects for all existing grid containers found by + * iterating below the given rootNode and optionally including nested frames. + * + * @param {NodeActor} rootNode + * @param {Boolean} traverseFrames + * Whether or not we should iterate through nested frames. + * @return {Array} An array of GridActor objects. + */ + getAllGrids: function (rootNode, traverseFrames) { + if (!traverseFrames) { + return this.getGridActors(rootNode); + } + + let grids = []; + for (let {document} of this.tabActor.windows) { + grids = [...grids, ...this.getGrids(document.documentElement)]; + } + + return grids; + }, + +}); + +exports.GridActor = GridActor; +exports.LayoutActor = LayoutActor; diff --git a/devtools/server/actors/memory.js b/devtools/server/actors/memory.js new file mode 100644 index 000000000..5c41a7dc1 --- /dev/null +++ b/devtools/server/actors/memory.js @@ -0,0 +1,83 @@ +/* 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 protocol = require("devtools/shared/protocol"); +const { Memory } = require("devtools/server/performance/memory"); +const { actorBridgeWithSpec } = require("devtools/server/actors/common"); +const { memorySpec } = require("devtools/shared/specs/memory"); +loader.lazyRequireGetter(this, "events", "sdk/event/core"); +loader.lazyRequireGetter(this, "StackFrameCache", + "devtools/server/actors/utils/stack", true); + +/** + * An actor that returns memory usage data for its parent actor's window. + * A tab-scoped instance of this actor will measure the memory footprint of its + * parent tab. A global-scoped instance however, will measure the memory + * footprint of the chrome window referenced by the root actor. + * + * This actor wraps the Memory module at devtools/server/performance/memory.js + * and provides RDP definitions. + * + * @see devtools/server/performance/memory.js for documentation. + */ +exports.MemoryActor = protocol.ActorClassWithSpec(memorySpec, { + initialize: function (conn, parent, frameCache = new StackFrameCache()) { + protocol.Actor.prototype.initialize.call(this, conn); + + this._onGarbageCollection = this._onGarbageCollection.bind(this); + this._onAllocations = this._onAllocations.bind(this); + this.bridge = new Memory(parent, frameCache); + this.bridge.on("garbage-collection", this._onGarbageCollection); + this.bridge.on("allocations", this._onAllocations); + }, + + destroy: function () { + this.bridge.off("garbage-collection", this._onGarbageCollection); + this.bridge.off("allocations", this._onAllocations); + this.bridge.destroy(); + protocol.Actor.prototype.destroy.call(this); + }, + + attach: actorBridgeWithSpec("attach"), + + detach: actorBridgeWithSpec("detach"), + + getState: actorBridgeWithSpec("getState"), + + saveHeapSnapshot: function (boundaries) { + return this.bridge.saveHeapSnapshot(boundaries); + }, + + takeCensus: actorBridgeWithSpec("takeCensus"), + + startRecordingAllocations: actorBridgeWithSpec("startRecordingAllocations"), + + stopRecordingAllocations: actorBridgeWithSpec("stopRecordingAllocations"), + + getAllocationsSettings: actorBridgeWithSpec("getAllocationsSettings"), + + getAllocations: actorBridgeWithSpec("getAllocations"), + + forceGarbageCollection: actorBridgeWithSpec("forceGarbageCollection"), + + forceCycleCollection: actorBridgeWithSpec("forceCycleCollection"), + + measure: actorBridgeWithSpec("measure"), + + residentUnique: actorBridgeWithSpec("residentUnique"), + + _onGarbageCollection: function (data) { + if (this.conn.transport) { + events.emit(this, "garbage-collection", data); + } + }, + + _onAllocations: function (data) { + if (this.conn.transport) { + events.emit(this, "allocations", data); + } + }, +}); diff --git a/devtools/server/actors/monitor.js b/devtools/server/actors/monitor.js new file mode 100644 index 000000000..17b076a9e --- /dev/null +++ b/devtools/server/actors/monitor.js @@ -0,0 +1,145 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const {Ci, Cc} = require("chrome"); +const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); +const Services = require("Services"); + +function MonitorActor(aConnection) { + this.conn = aConnection; + this._updates = []; + this._started = false; +} + +MonitorActor.prototype = { + actorPrefix: "monitor", + + // Updates. + + _sendUpdate: function () { + if (this._started) { + this.conn.sendActorEvent(this.actorID, "update", { data: this._updates }); + this._updates = []; + } + }, + + // Methods available from the front. + + start: function () { + if (!this._started) { + this._started = true; + Services.obs.addObserver(this, "devtools-monitor-update", false); + Services.obs.notifyObservers(null, "devtools-monitor-start", ""); + this._agents.forEach(agent => this._startAgent(agent)); + } + return {}; + }, + + stop: function () { + if (this._started) { + this._agents.forEach(agent => agent.stop()); + Services.obs.notifyObservers(null, "devtools-monitor-stop", ""); + Services.obs.removeObserver(this, "devtools-monitor-update"); + this._started = false; + } + return {}; + }, + + disconnect: function () { + this.stop(); + }, + + // nsIObserver. + + observe: function (subject, topic, data) { + if (topic == "devtools-monitor-update") { + try { + data = JSON.parse(data); + } catch (e) { + console.error("Observer notification data is not a valid JSON-string:", + data, e.message); + return; + } + if (!Array.isArray(data)) { + this._updates.push(data); + } else { + this._updates = this._updates.concat(data); + } + this._sendUpdate(); + } + }, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), + + // Update agents (see USSAgent for an example). + + _agents: [], + + _startAgent: function (agent) { + try { + agent.start(); + } catch (e) { + this._removeAgent(agent); + } + }, + + _addAgent: function (agent) { + this._agents.push(agent); + if (this._started) { + this._startAgent(agent); + } + }, + + _removeAgent: function (agent) { + let index = this._agents.indexOf(agent); + if (index > -1) { + this._agents.splice(index, 1); + } + }, +}; + +MonitorActor.prototype.requestTypes = { + "start": MonitorActor.prototype.start, + "stop": MonitorActor.prototype.stop, +}; + +exports.MonitorActor = MonitorActor; + +var USSAgent = { + _mgr: null, + _timeout: null, + _packet: { + graph: "USS", + time: null, + value: null + }, + + start: function () { + USSAgent._mgr = Cc["@mozilla.org/memory-reporter-manager;1"].getService(Ci.nsIMemoryReporterManager); + if (!USSAgent._mgr.residentUnique) { + throw "Couldn't get USS."; + } + USSAgent.update(); + }, + + update: function () { + if (!USSAgent._mgr) { + USSAgent.stop(); + return; + } + USSAgent._packet.time = Date.now(); + USSAgent._packet.value = USSAgent._mgr.residentUnique; + Services.obs.notifyObservers(null, "devtools-monitor-update", JSON.stringify(USSAgent._packet)); + USSAgent._timeout = setTimeout(USSAgent.update, 300); + }, + + stop: function () { + clearTimeout(USSAgent._timeout); + USSAgent._mgr = null; + } +}; + +MonitorActor.prototype._addAgent(USSAgent); diff --git a/devtools/server/actors/moz.build b/devtools/server/actors/moz.build new file mode 100644 index 000000000..5980876e2 --- /dev/null +++ b/devtools/server/actors/moz.build @@ -0,0 +1,69 @@ +# -*- 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/. + +DIRS += [ + 'highlighters', + 'utils', +] + +DevToolsModules( + 'actor-registry.js', + 'addon.js', + 'addons.js', + 'animation.js', + 'breakpoint.js', + 'call-watcher.js', + 'canvas.js', + 'child-process.js', + 'childtab.js', + 'chrome.js', + 'common.js', + 'css-properties.js', + 'csscoverage.js', + 'device.js', + 'director-manager.js', + 'director-registry.js', + 'emulation.js', + 'environment.js', + 'errordocs.js', + 'eventlooplag.js', + 'frame.js', + 'framerate.js', + 'gcli.js', + 'heap-snapshot-file.js', + 'highlighters.css', + 'highlighters.js', + 'inspector.js', + 'layout.js', + 'memory.js', + 'monitor.js', + 'object.js', + 'performance-entries.js', + 'performance-recording.js', + 'performance.js', + 'preference.js', + 'pretty-print-worker.js', + 'process.js', + 'profiler.js', + 'promises.js', + 'reflow.js', + 'root.js', + 'script.js', + 'settings.js', + 'source.js', + 'storage.js', + 'string.js', + 'styleeditor.js', + 'styles.js', + 'stylesheets.js', + 'timeline.js', + 'webaudio.js', + 'webbrowser.js', + 'webconsole.js', + 'webextension.js', + 'webgl.js', + 'worker.js', +) diff --git a/devtools/server/actors/object.js b/devtools/server/actors/object.js new file mode 100644 index 000000000..1f417b951 --- /dev/null +++ b/devtools/server/actors/object.js @@ -0,0 +1,2251 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; 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 { Cu, Ci } = require("chrome"); +const { GeneratedLocation } = require("devtools/server/actors/common"); +const { DebuggerServer } = require("devtools/server/main"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { assert, dumpn } = DevToolsUtils; + +loader.lazyRequireGetter(this, "ThreadSafeChromeUtils"); + +const TYPED_ARRAY_CLASSES = ["Uint8Array", "Uint8ClampedArray", "Uint16Array", + "Uint32Array", "Int8Array", "Int16Array", "Int32Array", "Float32Array", + "Float64Array"]; + +// Number of items to preview in objects, arrays, maps, sets, lists, +// collections, etc. +const OBJECT_PREVIEW_MAX_ITEMS = 10; + +/** + * Creates an actor for the specified object. + * + * @param obj Debugger.Object + * The debuggee object. + * @param hooks Object + * A collection of abstract methods that are implemented by the caller. + * ObjectActor requires the following functions to be implemented by + * the caller: + * - createValueGrip + * Creates a value grip for the given object + * - sources + * TabSources getter that manages the sources of a thread + * - createEnvironmentActor + * Creates and return an environment actor + * - getGripDepth + * An actor's grip depth getter + * - incrementGripDepth + * Increment the actor's grip depth + * - decrementGripDepth + * Decrement the actor's grip depth + * - globalDebugObject + * The Debuggee Global Object as given by the ThreadActor + */ +function ObjectActor(obj, { + createValueGrip, + sources, + createEnvironmentActor, + getGripDepth, + incrementGripDepth, + decrementGripDepth, + getGlobalDebugObject +}) { + assert(!obj.optimizedOut, + "Should not create object actors for optimized out values!"); + this.obj = obj; + this.hooks = { + createValueGrip, + sources, + createEnvironmentActor, + getGripDepth, + incrementGripDepth, + decrementGripDepth, + getGlobalDebugObject + }; + this.iterators = new Set(); +} + +ObjectActor.prototype = { + actorPrefix: "obj", + + /** + * Returns a grip for this actor for returning in a protocol message. + */ + grip: function () { + this.hooks.incrementGripDepth(); + + let g = { + "type": "object", + "actor": this.actorID + }; + + // If it's a proxy, lie and tell that it belongs to an invented + // "Proxy" class, and avoid calling the [[IsExtensible]] trap + if(this.obj.isProxy) { + g.class = "Proxy"; + g.proxyTarget = this.hooks.createValueGrip(this.obj.proxyTarget); + g.proxyHandler = this.hooks.createValueGrip(this.obj.proxyHandler); + } else { + g.class = this.obj.class; + g.extensible = this.obj.isExtensible(); + g.frozen = this.obj.isFrozen(); + g.sealed = this.obj.isSealed(); + } + + if (g.class != "DeadObject") { + if (g.class == "Promise") { + g.promiseState = this._createPromiseState(); + } + + // FF40+: Allow to know how many properties an object has + // to lazily display them when there is a bunch. + // Throws on some MouseEvent object in tests. + try { + // Bug 1163520: Assert on internal functions + if (!["Function", "Proxy"].includes(g.class)) { + g.ownPropertyLength = this.obj.getOwnPropertyNames().length; + } + } catch (e) {} + + let raw = this.obj.unsafeDereference(); + + // If Cu is not defined, we are running on a worker thread, where xrays + // don't exist. + if (Cu) { + raw = Cu.unwaiveXrays(raw); + } + + if (!DevToolsUtils.isSafeJSObject(raw)) { + raw = null; + } + + let previewers = DebuggerServer.ObjectActorPreviewers[g.class] || + DebuggerServer.ObjectActorPreviewers.Object; + for (let fn of previewers) { + try { + if (fn(this, g, raw)) { + break; + } + } catch (e) { + let msg = "ObjectActor.prototype.grip previewer function"; + DevToolsUtils.reportException(msg, e); + } + } + } + + this.hooks.decrementGripDepth(); + return g; + }, + + /** + * Returns an object exposing the internal Promise state. + */ + _createPromiseState: function () { + const { state, value, reason } = getPromiseState(this.obj); + let promiseState = { state }; + + if (state == "fulfilled") { + promiseState.value = this.hooks.createValueGrip(value); + } else if (state == "rejected") { + promiseState.reason = this.hooks.createValueGrip(reason); + } + + promiseState.creationTimestamp = Date.now() - this.obj.promiseLifetime; + + // Only add the timeToSettle property if the Promise isn't pending. + if (state !== "pending") { + promiseState.timeToSettle = this.obj.promiseTimeToResolution; + } + + return promiseState; + }, + + /** + * Releases this actor from the pool. + */ + release: function () { + if (this.registeredPool.objectActors) { + this.registeredPool.objectActors.delete(this.obj); + } + this.iterators.forEach(actor => this.registeredPool.removeActor(actor)); + this.iterators.clear(); + this.registeredPool.removeActor(this); + }, + + /** + * Handle a protocol request to provide the definition site of this function + * object. + */ + onDefinitionSite: function () { + if (this.obj.class != "Function") { + return { + from: this.actorID, + error: "objectNotFunction", + message: this.actorID + " is not a function." + }; + } + + if (!this.obj.script) { + return { + from: this.actorID, + error: "noScript", + message: this.actorID + " has no Debugger.Script" + }; + } + + return this.hooks.sources().getOriginalLocation(new GeneratedLocation( + this.hooks.sources().createNonSourceMappedActor(this.obj.script.source), + this.obj.script.startLine, + 0 // TODO bug 901138: use Debugger.Script.prototype.startColumn + )).then((originalLocation) => { + return { + source: originalLocation.originalSourceActor.form(), + line: originalLocation.originalLine, + column: originalLocation.originalColumn + }; + }); + }, + + /** + * Handle a protocol request to provide the names of the properties defined on + * the object and not its prototype. + */ + onOwnPropertyNames: function () { + return { from: this.actorID, + ownPropertyNames: this.obj.getOwnPropertyNames() }; + }, + + /** + * Creates an actor to iterate over an object property names and values. + * See PropertyIteratorActor constructor for more info about options param. + * + * @param request object + * The protocol request object. + */ + onEnumProperties: function (request) { + let actor = new PropertyIteratorActor(this, request.options); + this.registeredPool.addActor(actor); + this.iterators.add(actor); + return { iterator: actor.grip() }; + }, + + /** + * Creates an actor to iterate over entries of a Map/Set-like object. + */ + onEnumEntries: function () { + let actor = new PropertyIteratorActor(this, { enumEntries: true }); + this.registeredPool.addActor(actor); + this.iterators.add(actor); + return { iterator: actor.grip() }; + }, + + /** + * Handle a protocol request to provide the prototype and own properties of + * the object. + */ + onPrototypeAndProperties: function () { + let ownProperties = Object.create(null); + let names; + try { + names = this.obj.getOwnPropertyNames(); + } catch (ex) { + // The above can throw if this.obj points to a dead object. + // TODO: we should use Cu.isDeadWrapper() - see bug 885800. + return { from: this.actorID, + prototype: this.hooks.createValueGrip(null), + ownProperties: ownProperties, + safeGetterValues: Object.create(null) }; + } + for (let name of names) { + ownProperties[name] = this._propertyDescriptor(name); + } + return { from: this.actorID, + prototype: this.hooks.createValueGrip(this.obj.proto), + ownProperties: ownProperties, + safeGetterValues: this._findSafeGetterValues(names) }; + }, + + /** + * Find the safe getter values for the current Debugger.Object, |this.obj|. + * + * @private + * @param array ownProperties + * The array that holds the list of known ownProperties names for + * |this.obj|. + * @param number [limit=0] + * Optional limit of getter values to find. + * @return object + * An object that maps property names to safe getter descriptors as + * defined by the remote debugging protocol. + */ + _findSafeGetterValues: function (ownProperties, limit = 0) { + let safeGetterValues = Object.create(null); + let obj = this.obj; + let level = 0, i = 0; + + // Most objects don't have any safe getters but inherit some from their + // prototype. Avoid calling getOwnPropertyNames on objects that may have + // many properties like Array, strings or js objects. That to avoid + // freezing firefox when doing so. + if (TYPED_ARRAY_CLASSES.includes(this.obj.class) || + ["Array", "Object", "String"].includes(this.obj.class)) { + obj = obj.proto; + level++; + } + + while (obj) { + let getters = this._findSafeGetters(obj); + for (let name of getters) { + // Avoid overwriting properties from prototypes closer to this.obj. Also + // avoid providing safeGetterValues from prototypes if property |name| + // is already defined as an own property. + if (name in safeGetterValues || + (obj != this.obj && ownProperties.indexOf(name) !== -1)) { + continue; + } + + // Ignore __proto__ on Object.prototye. + if (!obj.proto && name == "__proto__") { + continue; + } + + let desc = null, getter = null; + try { + desc = obj.getOwnPropertyDescriptor(name); + getter = desc.get; + } catch (ex) { + // The above can throw if the cache becomes stale. + } + if (!getter) { + obj._safeGetters = null; + continue; + } + + let result = getter.call(this.obj); + if (result && !("throw" in result)) { + let getterValue = undefined; + if ("return" in result) { + getterValue = result.return; + } else if ("yield" in result) { + getterValue = result.yield; + } + // WebIDL attributes specified with the LenientThis extended attribute + // return undefined and should be ignored. + if (getterValue !== undefined) { + safeGetterValues[name] = { + getterValue: this.hooks.createValueGrip(getterValue), + getterPrototypeLevel: level, + enumerable: desc.enumerable, + writable: level == 0 ? desc.writable : true, + }; + if (limit && ++i == limit) { + break; + } + } + } + } + if (limit && i == limit) { + break; + } + + obj = obj.proto; + level++; + } + + return safeGetterValues; + }, + + /** + * Find the safe getters for a given Debugger.Object. Safe getters are native + * getters which are safe to execute. + * + * @private + * @param Debugger.Object object + * The Debugger.Object where you want to find safe getters. + * @return Set + * A Set of names of safe getters. This result is cached for each + * Debugger.Object. + */ + _findSafeGetters: function (object) { + if (object._safeGetters) { + return object._safeGetters; + } + + let getters = new Set(); + let names = []; + try { + names = object.getOwnPropertyNames(); + } catch (ex) { + // Calling getOwnPropertyNames() on some wrapped native prototypes is not + // allowed: "cannot modify properties of a WrappedNative". See bug 952093. + } + + for (let name of names) { + let desc = null; + try { + desc = object.getOwnPropertyDescriptor(name); + } catch (e) { + // Calling getOwnPropertyDescriptor on wrapped native prototypes is not + // allowed (bug 560072). + } + if (!desc || desc.value !== undefined || !("get" in desc)) { + continue; + } + + if (DevToolsUtils.hasSafeGetter(desc)) { + getters.add(name); + } + } + + object._safeGetters = getters; + return getters; + }, + + /** + * Handle a protocol request to provide the prototype of the object. + */ + onPrototype: function () { + return { from: this.actorID, + prototype: this.hooks.createValueGrip(this.obj.proto) }; + }, + + /** + * Handle a protocol request to provide the property descriptor of the + * object's specified property. + * + * @param request object + * The protocol request object. + */ + onProperty: function (request) { + if (!request.name) { + return { error: "missingParameter", + message: "no property name was specified" }; + } + + return { from: this.actorID, + descriptor: this._propertyDescriptor(request.name) }; + }, + + /** + * Handle a protocol request to provide the display string for the object. + */ + onDisplayString: function () { + const string = stringify(this.obj); + return { from: this.actorID, + displayString: this.hooks.createValueGrip(string) }; + }, + + /** + * A helper method that creates a property descriptor for the provided object, + * properly formatted for sending in a protocol response. + * + * @private + * @param string name + * The property that the descriptor is generated for. + * @param boolean [onlyEnumerable] + * Optional: true if you want a descriptor only for an enumerable + * property, false otherwise. + * @return object|undefined + * The property descriptor, or undefined if this is not an enumerable + * property and onlyEnumerable=true. + */ + _propertyDescriptor: function (name, onlyEnumerable) { + let desc; + try { + desc = this.obj.getOwnPropertyDescriptor(name); + } catch (e) { + // Calling getOwnPropertyDescriptor on wrapped native prototypes is not + // allowed (bug 560072). Inform the user with a bogus, but hopefully + // explanatory, descriptor. + return { + configurable: false, + writable: false, + enumerable: false, + value: e.name + }; + } + + if (!desc || onlyEnumerable && !desc.enumerable) { + return undefined; + } + + let retval = { + configurable: desc.configurable, + enumerable: desc.enumerable + }; + + if ("value" in desc) { + retval.writable = desc.writable; + retval.value = this.hooks.createValueGrip(desc.value); + } else { + if ("get" in desc) { + retval.get = this.hooks.createValueGrip(desc.get); + } + if ("set" in desc) { + retval.set = this.hooks.createValueGrip(desc.set); + } + } + return retval; + }, + + /** + * Handle a protocol request to provide the source code of a function. + * + * @param request object + * The protocol request object. + */ + onDecompile: function (request) { + if (this.obj.class !== "Function") { + return { error: "objectNotFunction", + message: "decompile request is only valid for object grips " + + "with a 'Function' class." }; + } + + return { from: this.actorID, + decompiledCode: this.obj.decompile(!!request.pretty) }; + }, + + /** + * Handle a protocol request to provide the parameters of a function. + */ + onParameterNames: function () { + if (this.obj.class !== "Function") { + return { error: "objectNotFunction", + message: "'parameterNames' request is only valid for object " + + "grips with a 'Function' class." }; + } + + return { parameterNames: this.obj.parameterNames }; + }, + + /** + * Handle a protocol request to release a thread-lifetime grip. + */ + onRelease: function () { + this.release(); + return {}; + }, + + /** + * Handle a protocol request to provide the lexical scope of a function. + */ + onScope: function () { + if (this.obj.class !== "Function") { + return { error: "objectNotFunction", + message: "scope request is only valid for object grips with a" + + " 'Function' class." }; + } + + let envActor = this.hooks.createEnvironmentActor(this.obj.environment, + this.registeredPool); + if (!envActor) { + return { error: "notDebuggee", + message: "cannot access the environment of this function." }; + } + + return { from: this.actorID, scope: envActor.form() }; + }, + + /** + * Handle a protocol request to get the list of dependent promises of a + * promise. + * + * @return object + * Returns an object containing an array of object grips of the + * dependent promises + */ + onDependentPromises: function () { + if (this.obj.class != "Promise") { + return { error: "objectNotPromise", + message: "'dependentPromises' request is only valid for " + + "object grips with a 'Promise' class." }; + } + + let promises = this.obj.promiseDependentPromises.map(p => this.hooks.createValueGrip(p)); + + return { promises }; + }, + + /** + * Handle a protocol request to get the allocation stack of a promise. + */ + onAllocationStack: function () { + if (this.obj.class != "Promise") { + return { error: "objectNotPromise", + message: "'allocationStack' request is only valid for " + + "object grips with a 'Promise' class." }; + } + + let stack = this.obj.promiseAllocationSite; + let allocationStacks = []; + + while (stack) { + if (stack.source) { + let source = this._getSourceOriginalLocation(stack); + + if (source) { + allocationStacks.push(source); + } + } + stack = stack.parent; + } + + return Promise.all(allocationStacks).then(stacks => { + return { allocationStack: stacks }; + }); + }, + + /** + * Handle a protocol request to get the fulfillment stack of a promise. + */ + onFulfillmentStack: function () { + if (this.obj.class != "Promise") { + return { error: "objectNotPromise", + message: "'fulfillmentStack' request is only valid for " + + "object grips with a 'Promise' class." }; + } + + let stack = this.obj.promiseResolutionSite; + let fulfillmentStacks = []; + + while (stack) { + if (stack.source) { + let source = this._getSourceOriginalLocation(stack); + + if (source) { + fulfillmentStacks.push(source); + } + } + stack = stack.parent; + } + + return Promise.all(fulfillmentStacks).then(stacks => { + return { fulfillmentStack: stacks }; + }); + }, + + /** + * Handle a protocol request to get the rejection stack of a promise. + */ + onRejectionStack: function () { + if (this.obj.class != "Promise") { + return { error: "objectNotPromise", + message: "'rejectionStack' request is only valid for " + + "object grips with a 'Promise' class." }; + } + + let stack = this.obj.promiseResolutionSite; + let rejectionStacks = []; + + while (stack) { + if (stack.source) { + let source = this._getSourceOriginalLocation(stack); + + if (source) { + rejectionStacks.push(source); + } + } + stack = stack.parent; + } + + return Promise.all(rejectionStacks).then(stacks => { + return { rejectionStack: stacks }; + }); + }, + + /** + * Helper function for fetching the source location of a SavedFrame stack. + * + * @param SavedFrame stack + * The promise allocation stack frame + * @return object + * Returns an object containing the source location of the SavedFrame + * stack. + */ + _getSourceOriginalLocation: function (stack) { + let source; + + // Catch any errors if the source actor cannot be found + try { + source = this.hooks.sources().getSourceActorByURL(stack.source); + } catch (e) {} + + if (!source) { + return null; + } + + return this.hooks.sources().getOriginalLocation(new GeneratedLocation( + source, + stack.line, + stack.column + )).then((originalLocation) => { + return { + source: originalLocation.originalSourceActor.form(), + line: originalLocation.originalLine, + column: originalLocation.originalColumn, + functionDisplayName: stack.functionDisplayName + }; + }); + } +}; + +ObjectActor.prototype.requestTypes = { + "definitionSite": ObjectActor.prototype.onDefinitionSite, + "parameterNames": ObjectActor.prototype.onParameterNames, + "prototypeAndProperties": ObjectActor.prototype.onPrototypeAndProperties, + "enumProperties": ObjectActor.prototype.onEnumProperties, + "prototype": ObjectActor.prototype.onPrototype, + "property": ObjectActor.prototype.onProperty, + "displayString": ObjectActor.prototype.onDisplayString, + "ownPropertyNames": ObjectActor.prototype.onOwnPropertyNames, + "decompile": ObjectActor.prototype.onDecompile, + "release": ObjectActor.prototype.onRelease, + "scope": ObjectActor.prototype.onScope, + "dependentPromises": ObjectActor.prototype.onDependentPromises, + "allocationStack": ObjectActor.prototype.onAllocationStack, + "fulfillmentStack": ObjectActor.prototype.onFulfillmentStack, + "rejectionStack": ObjectActor.prototype.onRejectionStack, + "enumEntries": ObjectActor.prototype.onEnumEntries, +}; + +/** + * Creates an actor to iterate over an object's property names and values. + * + * @param objectActor ObjectActor + * The object actor. + * @param options Object + * A dictionary object with various boolean attributes: + * - enumEntries Boolean + * If true, enumerates the entries of a Map or Set object + * instead of enumerating properties. + * - ignoreIndexedProperties Boolean + * If true, filters out Array items. + * e.g. properties names between `0` and `object.length`. + * - ignoreNonIndexedProperties Boolean + * If true, filters out items that aren't array items + * e.g. properties names that are not a number between `0` + * and `object.length`. + * - sort Boolean + * If true, the iterator will sort the properties by name + * before dispatching them. + * - query String + * If non-empty, will filter the properties by names and values + * containing this query string. The match is not case-sensitive. + * Regarding value filtering it just compare to the stringification + * of the property value. + */ +function PropertyIteratorActor(objectActor, options) { + if (options.enumEntries) { + let cls = objectActor.obj.class; + if (cls == "Map") { + this.iterator = enumMapEntries(objectActor); + } else if (cls == "WeakMap") { + this.iterator = enumWeakMapEntries(objectActor); + } else if (cls == "Set") { + this.iterator = enumSetEntries(objectActor); + } else if (cls == "WeakSet") { + this.iterator = enumWeakSetEntries(objectActor); + } else { + throw new Error("Unsupported class to enumerate entries from: " + cls); + } + } else if (options.ignoreNonIndexedProperties && !options.query) { + this.iterator = enumArrayProperties(objectActor, options); + } else { + this.iterator = enumObjectProperties(objectActor, options); + } +} + +PropertyIteratorActor.prototype = { + actorPrefix: "propertyIterator", + + grip() { + return { + type: this.actorPrefix, + actor: this.actorID, + count: this.iterator.size + }; + }, + + names({ indexes }) { + let list = []; + for (let idx of indexes) { + list.push(this.iterator.propertyName(idx)); + } + return { + names: indexes + }; + }, + + slice({ start, count }) { + let ownProperties = Object.create(null); + for (let i = start, m = start + count; i < m; i++) { + let name = this.iterator.propertyName(i); + ownProperties[name] = this.iterator.propertyDescription(i); + } + return { + ownProperties + }; + }, + + all() { + return this.slice({ start: 0, count: this.length }); + } +}; + +PropertyIteratorActor.prototype.requestTypes = { + "names": PropertyIteratorActor.prototype.names, + "slice": PropertyIteratorActor.prototype.slice, + "all": PropertyIteratorActor.prototype.all, +}; + +function enumArrayProperties(objectActor, options) { + let length = DevToolsUtils.getProperty(objectActor.obj, "length"); + if (typeof length !== "number") { + // Pseudo arrays are flagged as ArrayLike if they have + // subsequent indexed properties without having any length attribute. + length = 0; + let names = objectActor.obj.getOwnPropertyNames(); + for (let key of names) { + if (isNaN(key) || key != length++) { + break; + } + } + } + + return { + size: length, + propertyName(index) { + return index; + }, + propertyDescription(index) { + return objectActor._propertyDescriptor(index); + } + }; +} + +function enumObjectProperties(objectActor, options) { + let names = []; + try { + names = objectActor.obj.getOwnPropertyNames(); + } catch (ex) { + // Calling getOwnPropertyNames() on some wrapped native prototypes is not + // allowed: "cannot modify properties of a WrappedNative". See bug 952093. + } + + if (options.ignoreNonIndexedProperties || options.ignoreIndexedProperties) { + let length = DevToolsUtils.getProperty(objectActor.obj, "length"); + if (typeof length !== "number") { + // Pseudo arrays are flagged as ArrayLike if they have + // subsequent indexed properties without having any length attribute. + length = 0; + for (let key of names) { + if (isNaN(key) || key != length++) { + break; + } + } + } + + // It appears that getOwnPropertyNames always returns indexed properties + // first, so we can safely slice `names` for/against indexed properties. + // We do such clever operation to optimize very large array inspection, + // like webaudio buffers. + if (options.ignoreIndexedProperties) { + // Keep items after `length` index + names = names.slice(length); + } else if (options.ignoreNonIndexedProperties) { + // Remove `length` first items + names.splice(length); + } + } + + let safeGetterValues = objectActor._findSafeGetterValues(names, 0); + let safeGetterNames = Object.keys(safeGetterValues); + // Merge the safe getter values into the existing properties list. + for (let name of safeGetterNames) { + if (!names.includes(name)) { + names.push(name); + } + } + + if (options.query) { + let { query } = options; + query = query.toLowerCase(); + names = names.filter(name => { + // Filter on attribute names + if (name.toLowerCase().includes(query)) { + return true; + } + // and then on attribute values + let desc; + try { + desc = objectActor.obj.getOwnPropertyDescriptor(name); + } catch (e) { + // Calling getOwnPropertyDescriptor on wrapped native prototypes is not + // allowed (bug 560072). + } + if (desc && desc.value && + String(desc.value).includes(query)) { + return true; + } + return false; + }); + } + + if (options.sort) { + names.sort(); + } + + return { + size: names.length, + propertyName(index) { + return names[index]; + }, + propertyDescription(index) { + let name = names[index]; + let desc = objectActor._propertyDescriptor(name); + if (!desc) { + desc = safeGetterValues[name]; + } else if (name in safeGetterValues) { + // Merge the safe getter values into the existing properties list. + let { getterValue, getterPrototypeLevel } = safeGetterValues[name]; + desc.getterValue = getterValue; + desc.getterPrototypeLevel = getterPrototypeLevel; + } + return desc; + } + }; +} + +/** + * Helper function to create a grip from a Map/Set entry + */ +function gripFromEntry({ obj, hooks }, entry) { + return hooks.createValueGrip( + makeDebuggeeValueIfNeeded(obj, Cu.unwaiveXrays(entry))); +} + +function enumMapEntries(objectActor) { + // Iterating over a Map via .entries goes through various intermediate + // objects - an Iterator object, then a 2-element Array object, then the + // actual values we care about. We don't have Xrays to Iterator objects, + // so we get Opaque wrappers for them. And even though we have Xrays to + // Arrays, the semantics often deny access to the entires based on the + // nature of the values. So we need waive Xrays for the iterator object + // and the tupes, and then re-apply them on the underlying values until + // we fix bug 1023984. + // + // Even then though, we might want to continue waiving Xrays here for the + // same reason we do so for Arrays above - this filtering behavior is likely + // to be more confusing than beneficial in the case of Object previews. + let raw = objectActor.obj.unsafeDereference(); + + let keys = [...Cu.waiveXrays(Map.prototype.keys.call(raw))]; + return { + [Symbol.iterator]: function* () { + for (let key of keys) { + let value = Map.prototype.get.call(raw, key); + yield [ key, value ].map(val => gripFromEntry(objectActor, val)); + } + }, + size: keys.length, + propertyName(index) { + return index; + }, + propertyDescription(index) { + let key = keys[index]; + let val = Map.prototype.get.call(raw, key); + return { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: gripFromEntry(objectActor, key), + value: gripFromEntry(objectActor, val) + } + } + }; + } + }; +} + +function enumWeakMapEntries(objectActor) { + // We currently lack XrayWrappers for WeakMap, so when we iterate over + // the values, the temporary iterator objects get created in the target + // compartment. However, we _do_ have Xrays to Object now, so we end up + // Xraying those temporary objects, and filtering access to |it.value| + // based on whether or not it's Xrayable and/or callable, which breaks + // the for/of iteration. + // + // This code is designed to handle untrusted objects, so we can safely + // waive Xrays on the iterable, and relying on the Debugger machinery to + // make sure we handle the resulting objects carefully. + let raw = objectActor.obj.unsafeDereference(); + let keys = Cu.waiveXrays( + ThreadSafeChromeUtils.nondeterministicGetWeakMapKeys(raw)); + + return { + [Symbol.iterator]: function* () { + for (let key of keys) { + let value = WeakMap.prototype.get.call(raw, key); + yield [ key, value ].map(val => gripFromEntry(objectActor, val)); + } + }, + size: keys.length, + propertyName(index) { + return index; + }, + propertyDescription(index) { + let key = keys[index]; + let val = WeakMap.prototype.get.call(raw, key); + return { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: gripFromEntry(objectActor, key), + value: gripFromEntry(objectActor, val) + } + } + }; + } + }; +} + +function enumSetEntries(objectActor) { + // We currently lack XrayWrappers for Set, so when we iterate over + // the values, the temporary iterator objects get created in the target + // compartment. However, we _do_ have Xrays to Object now, so we end up + // Xraying those temporary objects, and filtering access to |it.value| + // based on whether or not it's Xrayable and/or callable, which breaks + // the for/of iteration. + // + // This code is designed to handle untrusted objects, so we can safely + // waive Xrays on the iterable, and relying on the Debugger machinery to + // make sure we handle the resulting objects carefully. + let raw = objectActor.obj.unsafeDereference(); + let values = [...Cu.waiveXrays(Set.prototype.values.call(raw))]; + + return { + [Symbol.iterator]: function* () { + for (let item of values) { + yield gripFromEntry(objectActor, item); + } + }, + size: values.length, + propertyName(index) { + return index; + }, + propertyDescription(index) { + let val = values[index]; + return { + enumerable: true, + value: gripFromEntry(objectActor, val) + }; + } + }; +} + +function enumWeakSetEntries(objectActor) { + // We currently lack XrayWrappers for WeakSet, so when we iterate over + // the values, the temporary iterator objects get created in the target + // compartment. However, we _do_ have Xrays to Object now, so we end up + // Xraying those temporary objects, and filtering access to |it.value| + // based on whether or not it's Xrayable and/or callable, which breaks + // the for/of iteration. + // + // This code is designed to handle untrusted objects, so we can safely + // waive Xrays on the iterable, and relying on the Debugger machinery to + // make sure we handle the resulting objects carefully. + let raw = objectActor.obj.unsafeDereference(); + let keys = Cu.waiveXrays( + ThreadSafeChromeUtils.nondeterministicGetWeakSetKeys(raw)); + + return { + [Symbol.iterator]: function* () { + for (let item of keys) { + yield gripFromEntry(objectActor, item); + } + }, + size: keys.length, + propertyName(index) { + return index; + }, + propertyDescription(index) { + let val = keys[index]; + return { + enumerable: true, + value: gripFromEntry(objectActor, val) + }; + } + }; +} + +/** + * Functions for adding information to ObjectActor grips for the purpose of + * having customized output. This object holds arrays mapped by + * Debugger.Object.prototype.class. + * + * In each array you can add functions that take three + * arguments: + * - the ObjectActor instance and its hooks to make a preview for, + * - the grip object being prepared for the client, + * - the raw JS object after calling Debugger.Object.unsafeDereference(). This + * argument is only provided if the object is safe for reading properties and + * executing methods. See DevToolsUtils.isSafeJSObject(). + * + * Functions must return false if they cannot provide preview + * information for the debugger object, or true otherwise. + */ +DebuggerServer.ObjectActorPreviewers = { + String: [function (objectActor, grip, rawObj) { + return wrappedPrimitivePreviewer("String", String, objectActor, grip, rawObj); + }], + + Boolean: [function (objectActor, grip, rawObj) { + return wrappedPrimitivePreviewer("Boolean", Boolean, objectActor, grip, rawObj); + }], + + Number: [function (objectActor, grip, rawObj) { + return wrappedPrimitivePreviewer("Number", Number, objectActor, grip, rawObj); + }], + + Function: [function ({obj, hooks}, grip) { + if (obj.name) { + grip.name = obj.name; + } + + if (obj.displayName) { + grip.displayName = obj.displayName.substr(0, 500); + } + + if (obj.parameterNames) { + grip.parameterNames = obj.parameterNames; + } + + // Check if the developer has added a de-facto standard displayName + // property for us to use. + let userDisplayName; + try { + userDisplayName = obj.getOwnPropertyDescriptor("displayName"); + } catch (e) { + // Calling getOwnPropertyDescriptor with displayName might throw + // with "permission denied" errors for some functions. + dumpn(e); + } + + if (userDisplayName && typeof userDisplayName.value == "string" && + userDisplayName.value) { + grip.userDisplayName = hooks.createValueGrip(userDisplayName.value); + } + + let dbgGlobal = hooks.getGlobalDebugObject(); + if (dbgGlobal) { + let script = dbgGlobal.makeDebuggeeValue(obj.unsafeDereference()).script; + if (script) { + grip.location = { + url: script.url, + line: script.startLine + }; + } + } + + return true; + }], + + RegExp: [function ({obj, hooks}, grip) { + // Avoid having any special preview for the RegExp.prototype itself. + if (!obj.proto || obj.proto.class != "RegExp") { + return false; + } + + let str = RegExp.prototype.toString.call(obj.unsafeDereference()); + grip.displayString = hooks.createValueGrip(str); + return true; + }], + + Date: [function ({obj, hooks}, grip) { + let time = Date.prototype.getTime.call(obj.unsafeDereference()); + + grip.preview = { + timestamp: hooks.createValueGrip(time), + }; + return true; + }], + + Array: [function ({obj, hooks}, grip) { + let length = DevToolsUtils.getProperty(obj, "length"); + if (typeof length != "number") { + return false; + } + + grip.preview = { + kind: "ArrayLike", + length: length, + }; + + if (hooks.getGripDepth() > 1) { + return true; + } + + let raw = obj.unsafeDereference(); + let items = grip.preview.items = []; + + for (let i = 0; i < length; ++i) { + // Array Xrays filter out various possibly-unsafe properties (like + // functions, and claim that the value is undefined instead. This + // is generally the right thing for privileged code accessing untrusted + // objects, but quite confusing for Object previews. So we manually + // override this protection by waiving Xrays on the array, and re-applying + // Xrays on any indexed value props that we pull off of it. + let desc = Object.getOwnPropertyDescriptor(Cu.waiveXrays(raw), i); + if (desc && !desc.get && !desc.set) { + let value = Cu.unwaiveXrays(desc.value); + value = makeDebuggeeValueIfNeeded(obj, value); + items.push(hooks.createValueGrip(value)); + } else { + items.push(null); + } + + if (items.length == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + + return true; + }], + + Set: [function (objectActor, grip) { + let size = DevToolsUtils.getProperty(objectActor.obj, "size"); + if (typeof size != "number") { + return false; + } + + grip.preview = { + kind: "ArrayLike", + length: size, + }; + + // Avoid recursive object grips. + if (objectActor.hooks.getGripDepth() > 1) { + return true; + } + + let items = grip.preview.items = []; + for (let item of enumSetEntries(objectActor)) { + items.push(item); + if (items.length == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + + return true; + }], + + WeakSet: [function (objectActor, grip) { + let enumEntries = enumWeakSetEntries(objectActor); + + grip.preview = { + kind: "ArrayLike", + length: enumEntries.size + }; + + // Avoid recursive object grips. + if (objectActor.hooks.getGripDepth() > 1) { + return true; + } + + let items = grip.preview.items = []; + for (let item of enumEntries) { + items.push(item); + if (items.length == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + + return true; + }], + + Map: [function (objectActor, grip) { + let size = DevToolsUtils.getProperty(objectActor.obj, "size"); + if (typeof size != "number") { + return false; + } + + grip.preview = { + kind: "MapLike", + size: size, + }; + + if (objectActor.hooks.getGripDepth() > 1) { + return true; + } + + let entries = grip.preview.entries = []; + for (let entry of enumMapEntries(objectActor)) { + entries.push(entry); + if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + + return true; + }], + + WeakMap: [function (objectActor, grip) { + let enumEntries = enumWeakMapEntries(objectActor); + + grip.preview = { + kind: "MapLike", + size: enumEntries.size + }; + + if (objectActor.hooks.getGripDepth() > 1) { + return true; + } + + let entries = grip.preview.entries = []; + for (let entry of enumEntries) { + entries.push(entry); + if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + + return true; + }], + + DOMStringMap: [function ({obj, hooks}, grip, rawObj) { + if (!rawObj) { + return false; + } + + let keys = obj.getOwnPropertyNames(); + grip.preview = { + kind: "MapLike", + size: keys.length, + }; + + if (hooks.getGripDepth() > 1) { + return true; + } + + let entries = grip.preview.entries = []; + for (let key of keys) { + let value = makeDebuggeeValueIfNeeded(obj, rawObj[key]); + entries.push([key, hooks.createValueGrip(value)]); + if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + + return true; + }], + + Proxy: [function ({obj, hooks}, grip, rawObj) { + grip.preview = { + kind: "Object", + ownProperties: Object.create(null), + ownPropertiesLength: 2 + }; + + if (hooks.getGripDepth() > 1) { + return true; + } + + grip.preview.ownProperties['<target>'] = {value: grip.proxyTarget}; + grip.preview.ownProperties['<handler>'] = {value: grip.proxyHandler}; + + return true; + }], +}; + +/** + * Generic previewer for classes wrapping primitives, like String, + * Number and Boolean. + * + * @param string className + * Class name to expect. + * @param object classObj + * The class to expect, eg. String. The valueOf() method of the class is + * invoked on the given object. + * @param ObjectActor objectActor + * The object actor + * @param Object grip + * The result grip to fill in + * @return Booolean true if the object was handled, false otherwise + */ +function wrappedPrimitivePreviewer(className, classObj, objectActor, grip, rawObj) { + let {obj, hooks} = objectActor; + + if (!obj.proto || obj.proto.class != className) { + return false; + } + + let v = null; + try { + v = classObj.prototype.valueOf.call(rawObj); + } catch (ex) { + // valueOf() can throw if the raw JS object is "misbehaved". + return false; + } + + if (v === null) { + return false; + } + + let canHandle = GenericObject(objectActor, grip, rawObj, className === "String"); + if (!canHandle) { + return false; + } + + grip.preview.wrappedValue = + hooks.createValueGrip(makeDebuggeeValueIfNeeded(obj, v)); + return true; +} + +function GenericObject(objectActor, grip, rawObj, specialStringBehavior = false) { + let {obj, hooks} = objectActor; + if (grip.preview || grip.displayString || hooks.getGripDepth() > 1) { + return false; + } + + let i = 0, names = []; + let preview = grip.preview = { + kind: "Object", + ownProperties: Object.create(null), + }; + + try { + names = obj.getOwnPropertyNames(); + } catch (ex) { + // Calling getOwnPropertyNames() on some wrapped native prototypes is not + // allowed: "cannot modify properties of a WrappedNative". See bug 952093. + } + + preview.ownPropertiesLength = names.length; + + let length; + if (specialStringBehavior) { + length = DevToolsUtils.getProperty(obj, "length"); + if (typeof length != "number") { + specialStringBehavior = false; + } + } + + for (let name of names) { + if (specialStringBehavior && /^[0-9]+$/.test(name)) { + let num = parseInt(name, 10); + if (num.toString() === name && num >= 0 && num < length) { + continue; + } + } + + let desc = objectActor._propertyDescriptor(name, true); + if (!desc) { + continue; + } + + preview.ownProperties[name] = desc; + if (++i == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + + if (i < OBJECT_PREVIEW_MAX_ITEMS) { + preview.safeGetterValues = objectActor._findSafeGetterValues( + Object.keys(preview.ownProperties), + OBJECT_PREVIEW_MAX_ITEMS - i); + } + + return true; +} + +// Preview functions that do not rely on the object class. +DebuggerServer.ObjectActorPreviewers.Object = [ + function TypedArray({obj, hooks}, grip) { + if (TYPED_ARRAY_CLASSES.indexOf(obj.class) == -1) { + return false; + } + + let length = DevToolsUtils.getProperty(obj, "length"); + if (typeof length != "number") { + return false; + } + + grip.preview = { + kind: "ArrayLike", + length: length, + }; + + if (hooks.getGripDepth() > 1) { + return true; + } + + let raw = obj.unsafeDereference(); + let global = Cu.getGlobalForObject(DebuggerServer); + let classProto = global[obj.class].prototype; + // The Xray machinery for TypedArrays denies indexed access on the grounds + // that it's slow, and advises callers to do a structured clone instead. + let safeView = Cu.cloneInto(classProto.subarray.call(raw, 0, + OBJECT_PREVIEW_MAX_ITEMS), global); + let items = grip.preview.items = []; + for (let i = 0; i < safeView.length; i++) { + items.push(safeView[i]); + } + + return true; + }, + + function Error({obj, hooks}, grip) { + switch (obj.class) { + case "Error": + case "EvalError": + case "RangeError": + case "ReferenceError": + case "SyntaxError": + case "TypeError": + case "URIError": + let name = DevToolsUtils.getProperty(obj, "name"); + let msg = DevToolsUtils.getProperty(obj, "message"); + let stack = DevToolsUtils.getProperty(obj, "stack"); + let fileName = DevToolsUtils.getProperty(obj, "fileName"); + let lineNumber = DevToolsUtils.getProperty(obj, "lineNumber"); + let columnNumber = DevToolsUtils.getProperty(obj, "columnNumber"); + grip.preview = { + kind: "Error", + name: hooks.createValueGrip(name), + message: hooks.createValueGrip(msg), + stack: hooks.createValueGrip(stack), + fileName: hooks.createValueGrip(fileName), + lineNumber: hooks.createValueGrip(lineNumber), + columnNumber: hooks.createValueGrip(columnNumber), + }; + return true; + default: + return false; + } + }, + + function CSSMediaRule({obj, hooks}, grip, rawObj) { + if (isWorker || !rawObj || !(rawObj instanceof Ci.nsIDOMCSSMediaRule)) { + return false; + } + grip.preview = { + kind: "ObjectWithText", + text: hooks.createValueGrip(rawObj.conditionText), + }; + return true; + }, + + function CSSStyleRule({obj, hooks}, grip, rawObj) { + if (isWorker || !rawObj || !(rawObj instanceof Ci.nsIDOMCSSStyleRule)) { + return false; + } + grip.preview = { + kind: "ObjectWithText", + text: hooks.createValueGrip(rawObj.selectorText), + }; + return true; + }, + + function ObjectWithURL({obj, hooks}, grip, rawObj) { + if (isWorker || !rawObj || !(rawObj instanceof Ci.nsIDOMCSSImportRule || + rawObj instanceof Ci.nsIDOMCSSStyleSheet || + rawObj instanceof Ci.nsIDOMLocation || + rawObj instanceof Ci.nsIDOMWindow)) { + return false; + } + + let url; + if (rawObj instanceof Ci.nsIDOMWindow && rawObj.location) { + url = rawObj.location.href; + } else if (rawObj.href) { + url = rawObj.href; + } else { + return false; + } + + grip.preview = { + kind: "ObjectWithURL", + url: hooks.createValueGrip(url), + }; + + return true; + }, + + function ArrayLike({obj, hooks}, grip, rawObj) { + if (isWorker || !rawObj || + obj.class != "DOMStringList" && + obj.class != "DOMTokenList" && + !(rawObj instanceof Ci.nsIDOMMozNamedAttrMap || + rawObj instanceof Ci.nsIDOMCSSRuleList || + rawObj instanceof Ci.nsIDOMCSSValueList || + rawObj instanceof Ci.nsIDOMFileList || + rawObj instanceof Ci.nsIDOMFontFaceList || + rawObj instanceof Ci.nsIDOMMediaList || + rawObj instanceof Ci.nsIDOMNodeList || + rawObj instanceof Ci.nsIDOMStyleSheetList)) { + return false; + } + + if (typeof rawObj.length != "number") { + return false; + } + + grip.preview = { + kind: "ArrayLike", + length: rawObj.length, + }; + + if (hooks.getGripDepth() > 1) { + return true; + } + + let items = grip.preview.items = []; + + for (let i = 0; i < rawObj.length && + items.length < OBJECT_PREVIEW_MAX_ITEMS; i++) { + let value = makeDebuggeeValueIfNeeded(obj, rawObj[i]); + items.push(hooks.createValueGrip(value)); + } + + return true; + }, + + function CSSStyleDeclaration({obj, hooks}, grip, rawObj) { + if (isWorker || !rawObj || + !(rawObj instanceof Ci.nsIDOMCSSStyleDeclaration)) { + return false; + } + + grip.preview = { + kind: "MapLike", + size: rawObj.length, + }; + + let entries = grip.preview.entries = []; + + for (let i = 0; i < OBJECT_PREVIEW_MAX_ITEMS && + i < rawObj.length; i++) { + let prop = rawObj[i]; + let value = rawObj.getPropertyValue(prop); + entries.push([prop, hooks.createValueGrip(value)]); + } + + return true; + }, + + function DOMNode({obj, hooks}, grip, rawObj) { + if (isWorker || obj.class == "Object" || !rawObj || + !(rawObj instanceof Ci.nsIDOMNode)) { + return false; + } + + let preview = grip.preview = { + kind: "DOMNode", + nodeType: rawObj.nodeType, + nodeName: rawObj.nodeName, + }; + + if (rawObj instanceof Ci.nsIDOMDocument && rawObj.location) { + preview.location = hooks.createValueGrip(rawObj.location.href); + } else if (rawObj instanceof Ci.nsIDOMDocumentFragment) { + preview.childNodesLength = rawObj.childNodes.length; + + if (hooks.getGripDepth() < 2) { + preview.childNodes = []; + for (let node of rawObj.childNodes) { + let actor = hooks.createValueGrip(obj.makeDebuggeeValue(node)); + preview.childNodes.push(actor); + if (preview.childNodes.length == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + } + } else if (rawObj instanceof Ci.nsIDOMElement) { + // Add preview for DOM element attributes. + if (rawObj instanceof Ci.nsIDOMHTMLElement) { + preview.nodeName = preview.nodeName.toLowerCase(); + } + + let i = 0; + preview.attributes = {}; + preview.attributesLength = rawObj.attributes.length; + for (let attr of rawObj.attributes) { + preview.attributes[attr.nodeName] = hooks.createValueGrip(attr.value); + } + } else if (rawObj instanceof Ci.nsIDOMAttr) { + preview.value = hooks.createValueGrip(rawObj.value); + } else if (rawObj instanceof Ci.nsIDOMText || + rawObj instanceof Ci.nsIDOMComment) { + preview.textContent = hooks.createValueGrip(rawObj.textContent); + } + + return true; + }, + + function DOMEvent({obj, hooks}, grip, rawObj) { + if (isWorker || !rawObj || !(rawObj instanceof Ci.nsIDOMEvent)) { + return false; + } + + let preview = grip.preview = { + kind: "DOMEvent", + type: rawObj.type, + properties: Object.create(null), + }; + + if (hooks.getGripDepth() < 2) { + let target = obj.makeDebuggeeValue(rawObj.target); + preview.target = hooks.createValueGrip(target); + } + + let props = []; + if (rawObj instanceof Ci.nsIDOMMouseEvent) { + props.push("buttons", "clientX", "clientY", "layerX", "layerY"); + } else if (rawObj instanceof Ci.nsIDOMKeyEvent) { + let modifiers = []; + if (rawObj.altKey) { + modifiers.push("Alt"); + } + if (rawObj.ctrlKey) { + modifiers.push("Control"); + } + if (rawObj.metaKey) { + modifiers.push("Meta"); + } + if (rawObj.shiftKey) { + modifiers.push("Shift"); + } + preview.eventKind = "key"; + preview.modifiers = modifiers; + + props.push("key", "charCode", "keyCode"); + } else if (rawObj instanceof Ci.nsIDOMTransitionEvent) { + props.push("propertyName", "pseudoElement"); + } else if (rawObj instanceof Ci.nsIDOMAnimationEvent) { + props.push("animationName", "pseudoElement"); + } else if (rawObj instanceof Ci.nsIDOMClipboardEvent) { + props.push("clipboardData"); + } + + // Add event-specific properties. + for (let prop of props) { + let value = rawObj[prop]; + if (value && (typeof value == "object" || typeof value == "function")) { + // Skip properties pointing to objects. + if (hooks.getGripDepth() > 1) { + continue; + } + value = obj.makeDebuggeeValue(value); + } + preview.properties[prop] = hooks.createValueGrip(value); + } + + // Add any properties we find on the event object. + if (!props.length) { + let i = 0; + for (let prop in rawObj) { + let value = rawObj[prop]; + if (prop == "target" || prop == "type" || value === null || + typeof value == "function") { + continue; + } + if (value && typeof value == "object") { + if (hooks.getGripDepth() > 1) { + continue; + } + value = obj.makeDebuggeeValue(value); + } + preview.properties[prop] = hooks.createValueGrip(value); + if (++i == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + } + + return true; + }, + + function DOMException({obj, hooks}, grip, rawObj) { + if (isWorker || !rawObj || !(rawObj instanceof Ci.nsIDOMDOMException)) { + return false; + } + + grip.preview = { + kind: "DOMException", + name: hooks.createValueGrip(rawObj.name), + message: hooks.createValueGrip(rawObj.message), + code: hooks.createValueGrip(rawObj.code), + result: hooks.createValueGrip(rawObj.result), + filename: hooks.createValueGrip(rawObj.filename), + lineNumber: hooks.createValueGrip(rawObj.lineNumber), + columnNumber: hooks.createValueGrip(rawObj.columnNumber), + }; + + return true; + }, + + function PseudoArray({obj, hooks}, grip, rawObj) { + let length; + + let keys = obj.getOwnPropertyNames(); + if (keys.length == 0) { + return false; + } + + // If no item is going to be displayed in preview, better display as sparse object. + // The first key should contain the smallest integer index (if any). + if(keys[0] >= OBJECT_PREVIEW_MAX_ITEMS) { + return false; + } + + // Pseudo-arrays should only have array indices and, optionally, a "length" property. + // Since integer indices are sorted first, check if the last property is "length". + if(keys[keys.length-1] === "length") { + keys.pop(); + length = DevToolsUtils.getProperty(obj, "length"); + } else { + // Otherwise, let length be the (presumably) greatest array index plus 1. + length = +keys[keys.length-1] + 1; + } + // Check if length is a valid array length, i.e. is a Uint32 number. + if(typeof length !== "number" || length >>> 0 !== length) { + return false; + } + + // Ensure all keys are increasing array indices smaller than length. The order is not + // guaranteed for exotic objects but, in most cases, big array indices and properties + // which are not integer indices should be at the end. Then, iterating backwards + // allows us to return earlier when the object is not completely a pseudo-array. + let prev = length; + for(let i = keys.length - 1; i >= 0; --i) { + let key = keys[i]; + let numKey = key >>> 0; // ToUint32(key) + if (numKey + '' !== key || numKey >= prev) { + return false; + } + prev = numKey; + } + + grip.preview = { + kind: "ArrayLike", + length: length, + }; + + // Avoid recursive object grips. + if (hooks.getGripDepth() > 1) { + return true; + } + + let items = grip.preview.items = []; + let numItems = Math.min(OBJECT_PREVIEW_MAX_ITEMS, length); + + for (let i = 0; i < numItems; ++i) { + let desc = obj.getOwnPropertyDescriptor(i); + if (desc && 'value' in desc) { + items.push(hooks.createValueGrip(desc.value)); + } else { + items.push(null); + } + } + + return true; + }, + + function Object(objectActor, grip, rawObj) { + return GenericObject(objectActor, grip, rawObj, /* specialStringBehavior = */ false); + }, +]; + +/** + * Get thisDebugger.Object referent's `promiseState`. + * + * @returns Object + * An object of one of the following forms: + * - { state: "pending" } + * - { state: "fulfilled", value } + * - { state: "rejected", reason } + */ +function getPromiseState(obj) { + if (obj.class != "Promise") { + throw new Error( + "Can't call `getPromiseState` on `Debugger.Object`s that don't " + + "refer to Promise objects."); + } + + let state = { state: obj.promiseState }; + if (state.state === "fulfilled") { + state.value = obj.promiseValue; + } else if (state.state === "rejected") { + state.reason = obj.promiseReason; + } + return state; +} + +/** + * Determine if a given value is non-primitive. + * + * @param Any value + * The value to test. + * @return Boolean + * Whether the value is non-primitive. + */ +function isObject(value) { + const type = typeof value; + return type == "object" ? value !== null : type == "function"; +} + +/** + * Create a function that can safely stringify Debugger.Objects of a given + * builtin type. + * + * @param Function ctor + * The builtin class constructor. + * @return Function + * The stringifier for the class. + */ +function createBuiltinStringifier(ctor) { + return obj => ctor.prototype.toString.call(obj.unsafeDereference()); +} + +/** + * Stringify a Debugger.Object-wrapped Error instance. + * + * @param Debugger.Object obj + * The object to stringify. + * @return String + * The stringification of the object. + */ +function errorStringify(obj) { + let name = DevToolsUtils.getProperty(obj, "name"); + if (name === "" || name === undefined) { + name = obj.class; + } else if (isObject(name)) { + name = stringify(name); + } + + let message = DevToolsUtils.getProperty(obj, "message"); + if (isObject(message)) { + message = stringify(message); + } + + if (message === "" || message === undefined) { + return name; + } + return name + ": " + message; +} + +/** + * Stringify a Debugger.Object based on its class. + * + * @param Debugger.Object obj + * The object to stringify. + * @return String + * The stringification for the object. + */ +function stringify(obj) { + if (obj.class == "DeadObject") { + const error = new Error("Dead object encountered."); + DevToolsUtils.reportException("stringify", error); + return "<dead object>"; + } + + const stringifier = stringifiers[obj.class] || stringifiers.Object; + + try { + return stringifier(obj); + } catch (e) { + DevToolsUtils.reportException("stringify", e); + return "<failed to stringify object>"; + } +} + +// Used to prevent infinite recursion when an array is found inside itself. +var seen = null; + +var stringifiers = { + Error: errorStringify, + EvalError: errorStringify, + RangeError: errorStringify, + ReferenceError: errorStringify, + SyntaxError: errorStringify, + TypeError: errorStringify, + URIError: errorStringify, + Boolean: createBuiltinStringifier(Boolean), + Function: createBuiltinStringifier(Function), + Number: createBuiltinStringifier(Number), + RegExp: createBuiltinStringifier(RegExp), + String: createBuiltinStringifier(String), + Object: obj => "[object " + obj.class + "]", + Array: obj => { + // If we're at the top level then we need to create the Set for tracking + // previously stringified arrays. + const topLevel = !seen; + if (topLevel) { + seen = new Set(); + } else if (seen.has(obj)) { + return ""; + } + + seen.add(obj); + + const len = DevToolsUtils.getProperty(obj, "length"); + let string = ""; + + // The following check is only required because the debuggee could possibly + // be a Proxy and return any value. For normal objects, array.length is + // always a non-negative integer. + if (typeof len == "number" && len > 0) { + for (let i = 0; i < len; i++) { + const desc = obj.getOwnPropertyDescriptor(i); + if (desc) { + const { value } = desc; + if (value != null) { + string += isObject(value) ? stringify(value) : value; + } + } + + if (i < len - 1) { + string += ","; + } + } + } + + if (topLevel) { + seen = null; + } + + return string; + }, + DOMException: obj => { + const message = DevToolsUtils.getProperty(obj, "message") || "<no message>"; + const result = (+DevToolsUtils.getProperty(obj, "result")).toString(16); + const code = DevToolsUtils.getProperty(obj, "code"); + const name = DevToolsUtils.getProperty(obj, "name") || "<unknown>"; + + return '[Exception... "' + message + '" ' + + 'code: "' + code + '" ' + + 'nsresult: "0x' + result + " (" + name + ')"]'; + }, + Promise: obj => { + const { state, value, reason } = getPromiseState(obj); + let statePreview = state; + if (state != "pending") { + const settledValue = state === "fulfilled" ? value : reason; + statePreview += ": " + (typeof settledValue === "object" && settledValue !== null + ? stringify(settledValue) + : settledValue); + } + return "Promise (" + statePreview + ")"; + }, +}; + +/** + * Make a debuggee value for the given object, if needed. Primitive values + * are left the same. + * + * Use case: you have a raw JS object (after unsafe dereference) and you want to + * send it to the client. In that case you need to use an ObjectActor which + * requires a debuggee value. The Debugger.Object.prototype.makeDebuggeeValue() + * method works only for JS objects and functions. + * + * @param Debugger.Object obj + * @param any value + * @return object + */ +function makeDebuggeeValueIfNeeded(obj, value) { + if (value && (typeof value == "object" || typeof value == "function")) { + return obj.makeDebuggeeValue(value); + } + return value; +} + +/** + * Creates an actor for the specied "very long" string. "Very long" is specified + * at the server's discretion. + * + * @param string String + * The string. + */ +function LongStringActor(string) { + this.string = string; + this.stringLength = string.length; +} + +LongStringActor.prototype = { + actorPrefix: "longString", + + disconnect: function () { + // Because longStringActors is not a weak map, we won't automatically leave + // it so we need to manually leave on disconnect so that we don't leak + // memory. + this._releaseActor(); + }, + + /** + * Returns a grip for this actor for returning in a protocol message. + */ + grip: function () { + return { + "type": "longString", + "initial": this.string.substring( + 0, DebuggerServer.LONG_STRING_INITIAL_LENGTH), + "length": this.stringLength, + "actor": this.actorID + }; + }, + + /** + * Handle a request to extract part of this actor's string. + * + * @param request object + * The protocol request object. + */ + onSubstring: function (request) { + return { + "from": this.actorID, + "substring": this.string.substring(request.start, request.end) + }; + }, + + /** + * Handle a request to release this LongStringActor instance. + */ + onRelease: function () { + // TODO: also check if registeredPool === threadActor.threadLifetimePool + // when the web console moves aray from manually releasing pause-scoped + // actors. + this._releaseActor(); + this.registeredPool.removeActor(this); + return {}; + }, + + _releaseActor: function () { + if (this.registeredPool && this.registeredPool.longStringActors) { + delete this.registeredPool.longStringActors[this.string]; + } + } +}; + +LongStringActor.prototype.requestTypes = { + "substring": LongStringActor.prototype.onSubstring, + "release": LongStringActor.prototype.onRelease +}; + +/** + * Create a grip for the given debuggee value. If the value is an + * object, will create an actor with the given lifetime. + */ +function createValueGrip(value, pool, makeObjectGrip) { + switch (typeof value) { + case "boolean": + return value; + + case "string": + if (stringIsLong(value)) { + return longStringGrip(value, pool); + } + return value; + + case "number": + if (value === Infinity) { + return { type: "Infinity" }; + } else if (value === -Infinity) { + return { type: "-Infinity" }; + } else if (Number.isNaN(value)) { + return { type: "NaN" }; + } else if (!value && 1 / value === -Infinity) { + return { type: "-0" }; + } + return value; + + case "undefined": + return { type: "undefined" }; + + case "object": + if (value === null) { + return { type: "null" }; + } + else if (value.optimizedOut || + value.uninitialized || + value.missingArguments) { + // The slot is optimized out, an uninitialized binding, or + // arguments on a dead scope + return { + type: "null", + optimizedOut: value.optimizedOut, + uninitialized: value.uninitialized, + missingArguments: value.missingArguments + }; + } + return makeObjectGrip(value, pool); + + case "symbol": + let form = { + type: "symbol" + }; + let name = getSymbolName(value); + if (name !== undefined) { + form.name = createValueGrip(name, pool, makeObjectGrip); + } + return form; + + default: + assert(false, "Failed to provide a grip for: " + value); + return null; + } +} + +const symbolProtoToString = Symbol.prototype.toString; + +function getSymbolName(symbol) { + const name = symbolProtoToString.call(symbol).slice("Symbol(".length, -1); + return name || undefined; +} + +/** + * Returns true if the string is long enough to use a LongStringActor instead + * of passing the value directly over the protocol. + * + * @param str String + * The string we are checking the length of. + */ +function stringIsLong(str) { + return str.length >= DebuggerServer.LONG_STRING_LENGTH; +} + +/** + * Create a grip for the given string. + * + * @param str String + * The string we are creating a grip for. + * @param pool ActorPool + * The actor pool where the new actor will be added. + */ +function longStringGrip(str, pool) { + if (!pool.longStringActors) { + pool.longStringActors = {}; + } + + if (pool.longStringActors.hasOwnProperty(str)) { + return pool.longStringActors[str].grip(); + } + + let actor = new LongStringActor(str); + pool.addActor(actor); + pool.longStringActors[str] = actor; + return actor.grip(); +} + +exports.ObjectActor = ObjectActor; +exports.PropertyIteratorActor = PropertyIteratorActor; +exports.LongStringActor = LongStringActor; +exports.createValueGrip = createValueGrip; +exports.stringIsLong = stringIsLong; +exports.longStringGrip = longStringGrip; diff --git a/devtools/server/actors/performance-entries.js b/devtools/server/actors/performance-entries.js new file mode 100644 index 000000000..89434324a --- /dev/null +++ b/devtools/server/actors/performance-entries.js @@ -0,0 +1,65 @@ +/* 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/. */ + +/** + * The performanceEntries actor emits events corresponding to performance + * entries. It receives `performanceentry` events containing the performance + * entry details and emits an event containing the name, type, origin, and + * epoch of the performance entry. + */ + +const { Actor, ActorClassWithSpec } = require("devtools/shared/protocol"); +const performanceSpec = require("devtools/shared/specs/performance-entries"); +const events = require("sdk/event/core"); + +var PerformanceEntriesActor = ActorClassWithSpec(performanceSpec, { + listenerAdded: false, + + initialize: function (conn, tabActor) { + Actor.prototype.initialize.call(this, conn); + this.window = tabActor.window; + }, + + /** + * Start tracking the user timings. + */ + start: function () { + if (!this.listenerAdded) { + this.onPerformanceEntry = this.onPerformanceEntry.bind(this); + this.window.addEventListener("performanceentry", this.onPerformanceEntry, true); + this.listenerAdded = true; + } + }, + + /** + * Stop tracking the user timings. + */ + stop: function () { + if (this.listenerAdded) { + this.window.removeEventListener("performanceentry", this.onPerformanceEntry, true); + this.listenerAdded = false; + } + }, + + disconnect: function () { + this.destroy(); + }, + + destroy: function () { + this.stop(); + Actor.prototype.destroy.call(this); + }, + + onPerformanceEntry: function (e) { + let emitDetail = { + type: e.entryType, + name: e.name, + origin: e.origin, + epoch: e.epoch + }; + events.emit(this, "entry", emitDetail); + } +}); + +exports.PerformanceEntriesActor = PerformanceEntriesActor; diff --git a/devtools/server/actors/performance-recording.js b/devtools/server/actors/performance-recording.js new file mode 100644 index 000000000..ef5907495 --- /dev/null +++ b/devtools/server/actors/performance-recording.js @@ -0,0 +1,148 @@ +/* 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 } = require("chrome"); +const { Actor, ActorClassWithSpec } = require("devtools/shared/protocol"); +const { performanceRecordingSpec } = require("devtools/shared/specs/performance-recording"); + +loader.lazyRequireGetter(this, "merge", "sdk/util/object", true); +loader.lazyRequireGetter(this, "RecordingUtils", + "devtools/shared/performance/recording-utils"); +loader.lazyRequireGetter(this, "PerformanceRecordingCommon", + "devtools/shared/performance/recording-common", true); + +/** + * This actor wraps the Performance module at devtools/shared/shared/performance.js + * and provides RDP definitions. + * + * @see devtools/shared/shared/performance.js for documentation. + */ +const PerformanceRecordingActor = ActorClassWithSpec(performanceRecordingSpec, merge({ + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let form = { + actor: this.actorID, // actorID is set when this is added to a pool + configuration: this._configuration, + startingBufferStatus: this._startingBufferStatus, + console: this._console, + label: this._label, + startTime: this._startTime, + localStartTime: this._localStartTime, + recording: this._recording, + completed: this._completed, + duration: this._duration, + }; + + // Only send profiler data once it exists and it has + // not yet been sent + if (this._profile && !this._sentFinalizedData) { + form.finalizedData = true; + form.profile = this.getProfile(); + form.systemHost = this.getHostSystemInfo(); + form.systemClient = this.getClientSystemInfo(); + this._sentFinalizedData = true; + } + + return form; + }, + + /** + * @param {object} conn + * @param {object} options + * A hash of features that this recording is utilizing. + * @param {object} meta + * A hash of temporary metadata for a recording that is recording + * (as opposed to an imported recording). + */ + initialize: function (conn, options, meta) { + Actor.prototype.initialize.call(this, conn); + this._configuration = { + withMarkers: options.withMarkers || false, + withTicks: options.withTicks || false, + withMemory: options.withMemory || false, + withAllocations: options.withAllocations || false, + allocationsSampleProbability: options.allocationsSampleProbability || 0, + allocationsMaxLogLength: options.allocationsMaxLogLength || 0, + bufferSize: options.bufferSize || 0, + sampleFrequency: options.sampleFrequency || 1 + }; + + this._console = !!options.console; + this._label = options.label || ""; + + if (meta) { + // Store the start time roughly with Date.now() so when we + // are checking the duration during a recording, we can get close + // to the approximate duration to render elements without + // making a real request + this._localStartTime = Date.now(); + + this._startTime = meta.startTime; + this._startingBufferStatus = { + position: meta.position, + totalSize: meta.totalSize, + generation: meta.generation + }; + + this._recording = true; + this._markers = []; + this._frames = []; + this._memory = []; + this._ticks = []; + this._allocations = { sites: [], timestamps: [], frames: [], sizes: [] }; + + this._systemHost = meta.systemHost || {}; + this._systemClient = meta.systemClient || {}; + } + }, + + destroy: function () { + Actor.prototype.destroy.call(this); + }, + + /** + * Internal utility called by the PerformanceActor and PerformanceFront on state changes + * to update the internal state of the PerformanceRecording. + * + * @param {string} state + * @param {object} extraData + */ + _setState: function (state, extraData) { + switch (state) { + case "recording-started": { + this._recording = true; + break; + } + case "recording-stopping": { + this._recording = false; + break; + } + case "recording-stopped": { + this._profile = extraData.profile; + this._duration = extraData.duration; + + // We filter out all samples that fall out of current profile's range + // since the profiler is continuously running. Because of this, sample + // times are not guaranteed to have a zero epoch, so offset the + // timestamps. + RecordingUtils.offsetSampleTimes(this._profile, this._startTime); + + // Markers need to be sorted ascending by time, to be properly displayed + // in a waterfall view. + this._markers = this._markers.sort((a, b) => (a.start > b.start)); + + this._completed = true; + break; + } + } + }, + +}, PerformanceRecordingCommon)); + +exports.PerformanceRecordingActor = PerformanceRecordingActor; diff --git a/devtools/server/actors/performance.js b/devtools/server/actors/performance.js new file mode 100644 index 000000000..8b294a4de --- /dev/null +++ b/devtools/server/actors/performance.js @@ -0,0 +1,116 @@ +/* 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 } = require("chrome"); +const { Task } = require("devtools/shared/task"); +const { Actor, ActorClassWithSpec } = require("devtools/shared/protocol"); +const { actorBridgeWithSpec } = require("devtools/server/actors/common"); +const { performanceSpec } = require("devtools/shared/specs/performance"); + +loader.lazyRequireGetter(this, "events", "sdk/event/core"); +loader.lazyRequireGetter(this, "extend", "sdk/util/object", true); + +loader.lazyRequireGetter(this, "PerformanceRecorder", + "devtools/server/performance/recorder", true); +loader.lazyRequireGetter(this, "normalizePerformanceFeatures", + "devtools/shared/performance/recording-utils", true); + +const PIPE_TO_FRONT_EVENTS = new Set([ + "recording-started", "recording-stopping", "recording-stopped", + "profiler-status", "timeline-data", "console-profile-start" +]); + +const RECORDING_STATE_CHANGE_EVENTS = new Set([ + "recording-started", "recording-stopping", "recording-stopped" +]); + +/** + * This actor wraps the Performance module at devtools/shared/shared/performance.js + * and provides RDP definitions. + * + * @see devtools/shared/shared/performance.js for documentation. + */ +var PerformanceActor = ActorClassWithSpec(performanceSpec, { + traits: { + features: { + withMarkers: true, + withTicks: true, + withMemory: true, + withFrames: true, + withGCEvents: true, + withDocLoadingEvents: true, + withAllocations: true, + }, + }, + + initialize: function (conn, tabActor) { + Actor.prototype.initialize.call(this, conn); + this._onRecorderEvent = this._onRecorderEvent.bind(this); + this.bridge = new PerformanceRecorder(conn, tabActor); + events.on(this.bridge, "*", this._onRecorderEvent); + }, + + /** + * `disconnect` method required to call destroy, since this + * actor is not managed by a parent actor. + */ + disconnect: function () { + this.destroy(); + }, + + destroy: function () { + events.off(this.bridge, "*", this._onRecorderEvent); + this.bridge.destroy(); + Actor.prototype.destroy.call(this); + }, + + connect: function (config) { + this.bridge.connect({ systemClient: config.systemClient }); + return { traits: this.traits }; + }, + + canCurrentlyRecord: function () { + return this.bridge.canCurrentlyRecord(); + }, + + startRecording: Task.async(function* (options = {}) { + if (!this.bridge.canCurrentlyRecord().success) { + return null; + } + + let normalizedOptions = normalizePerformanceFeatures(options, this.traits.features); + let recording = yield this.bridge.startRecording(normalizedOptions); + this.manage(recording); + + return recording; + }), + + stopRecording: actorBridgeWithSpec("stopRecording"), + isRecording: actorBridgeWithSpec("isRecording"), + getRecordings: actorBridgeWithSpec("getRecordings"), + getConfiguration: actorBridgeWithSpec("getConfiguration"), + setProfilerStatusInterval: actorBridgeWithSpec("setProfilerStatusInterval"), + + /** + * Filter which events get piped to the front. + */ + _onRecorderEvent: function (eventName, ...data) { + // If this is a recording state change, call + // a method on the related PerformanceRecordingActor so it can + // update its internal state. + if (RECORDING_STATE_CHANGE_EVENTS.has(eventName)) { + let recording = data[0]; + let extraData = data[1]; + recording._setState(eventName, extraData); + } + + if (PIPE_TO_FRONT_EVENTS.has(eventName)) { + events.emit(this, eventName, ...data); + } + }, +}); + +exports.PerformanceActor = PerformanceActor; diff --git a/devtools/server/actors/preference.js b/devtools/server/actors/preference.js new file mode 100644 index 000000000..8d4140155 --- /dev/null +++ b/devtools/server/actors/preference.js @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const {Cc, Ci, Cu, CC} = require("chrome"); +const protocol = require("devtools/shared/protocol"); +const {Arg, method, RetVal} = protocol; +const Services = require("Services"); +const {preferenceSpec} = require("devtools/shared/specs/preference"); + +exports.register = function (handle) { + handle.addGlobalActor(PreferenceActor, "preferenceActor"); +}; + +exports.unregister = function (handle) { +}; + +var PreferenceActor = exports.PreferenceActor = protocol.ActorClassWithSpec(preferenceSpec, { + typeName: "preference", + + getBoolPref: function (name) { + return Services.prefs.getBoolPref(name); + }, + + getCharPref: function (name) { + return Services.prefs.getCharPref(name); + }, + + getIntPref: function (name) { + return Services.prefs.getIntPref(name); + }, + + getAllPrefs: function () { + let prefs = {}; + Services.prefs.getChildList("").forEach(function (name, index) { + // append all key/value pairs into a huge json object. + try { + let value; + switch (Services.prefs.getPrefType(name)) { + case Ci.nsIPrefBranch.PREF_STRING: + value = Services.prefs.getCharPref(name); + break; + case Ci.nsIPrefBranch.PREF_INT: + value = Services.prefs.getIntPref(name); + break; + case Ci.nsIPrefBranch.PREF_BOOL: + value = Services.prefs.getBoolPref(name); + break; + default: + } + prefs[name] = { + value: value, + hasUserValue: Services.prefs.prefHasUserValue(name) + }; + } catch (e) { + // pref exists but has no user or default value + } + }); + return prefs; + }, + + setBoolPref: function (name, value) { + Services.prefs.setBoolPref(name, value); + Services.prefs.savePrefFile(null); + }, + + setCharPref: function (name, value) { + Services.prefs.setCharPref(name, value); + Services.prefs.savePrefFile(null); + }, + + setIntPref: function (name, value) { + Services.prefs.setIntPref(name, value); + Services.prefs.savePrefFile(null); + }, + + clearUserPref: function (name) { + Services.prefs.clearUserPref(name); + Services.prefs.savePrefFile(null); + }, +}); diff --git a/devtools/server/actors/pretty-print-worker.js b/devtools/server/actors/pretty-print-worker.js new file mode 100644 index 000000000..5fc6b6959 --- /dev/null +++ b/devtools/server/actors/pretty-print-worker.js @@ -0,0 +1,50 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; 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/. */ + +/** + * This file is meant to be loaded as a ChromeWorker. It accepts messages which + * have data of the form: + * + * { id, url, indent, source } + * + * Where `id` is a unique ID to identify this request, `url` is the url of the + * source being pretty printed, `indent` is the number of spaces to indent the + * code by, and `source` is the source text. + * + * On success, the worker responds with a message of the form: + * + * { id, code, mappings } + * + * Where `id` is the same unique ID from the request, `code` is the pretty + * printed source text, and `mappings` is an array or source mappings from the + * pretty printed code back to the ugly source text. + * + * In the case of an error, the worker responds with a message of the form: + * + * { id, error } + */ + +importScripts("resource://devtools/shared/worker/helper.js"); +importScripts("resource://devtools/shared/acorn/acorn.js"); +importScripts("resource://devtools/shared/sourcemap/source-map.js"); +importScripts("resource://devtools/shared/pretty-fast/pretty-fast.js"); + +workerHelper.createTask(self, "pretty-print", ({ url, indent, source }) => { + try { + const prettified = prettyFast(source, { + url: url, + indent: " ".repeat(indent) + }); + + return { + code: prettified.code, + mappings: prettified.map._mappings + }; + } + catch (e) { + return new Error(e.message + "\n" + e.stack); + } +}); diff --git a/devtools/server/actors/process.js b/devtools/server/actors/process.js new file mode 100644 index 000000000..ff1c4313f --- /dev/null +++ b/devtools/server/actors/process.js @@ -0,0 +1,83 @@ +/* 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 { Cc, Ci } = require("chrome"); + +loader.lazyGetter(this, "ppmm", () => { + return Cc["@mozilla.org/parentprocessmessagemanager;1"].getService(Ci.nsIMessageBroadcaster); +}); + +function ProcessActorList() { + this._actors = new Map(); + this._onListChanged = null; + this._mustNotify = false; + + this._onMessage = this._onMessage.bind(this); + this._processScript = "data:text/javascript,sendAsyncMessage('debug:new-process');"; +} + +ProcessActorList.prototype = { + getList: function () { + let processes = []; + for (let i = 0; i < ppmm.childCount; i++) { + processes.push({ + id: i, // XXX: may not be a perfect id, but process message manager doesn't expose anything... + parent: i == 0, // XXX Weak, but appear to be stable + tabCount: undefined, // TODO: exposes process message manager on frameloaders in order to compute this + }); + } + this._mustNotify = true; + this._checkListening(); + + return processes; + }, + + get onListChanged() { + return this._onListChanged; + }, + + set onListChanged(onListChanged) { + if (typeof onListChanged !== "function" && onListChanged !== null) { + throw new Error("onListChanged must be either a function or null."); + } + if (onListChanged === this._onListChanged) { + return; + } + + this._onListChanged = onListChanged; + this._checkListening(); + }, + + _checkListening: function () { + if (this._onListChanged !== null && this._mustNotify) { + this._knownProcesses = []; + for (let i = 0; i < ppmm.childCount; i++) { + this._knownProcesses.push(ppmm.getChildAt(i)); + } + ppmm.addMessageListener("debug:new-process", this._onMessage); + ppmm.loadProcessScript(this._processScript, true); + } else { + ppmm.removeMessageListener("debug:new-process", this._onMessage); + ppmm.removeDelayedProcessScript(this._processScript); + } + }, + + _notifyListChanged: function () { + if (this._mustNotify) { + this._onListChanged(); + this._mustNotify = false; + } + }, + + _onMessage: function ({ target }) { + if (this._knownProcesses.includes(target)) { + return; + } + this._notifyListChanged(); + }, +}; + +exports.ProcessActorList = ProcessActorList; diff --git a/devtools/server/actors/profiler.js b/devtools/server/actors/profiler.js new file mode 100644 index 000000000..c4b594408 --- /dev/null +++ b/devtools/server/actors/profiler.js @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { Actor, ActorClassWithSpec } = require("devtools/shared/protocol"); +const { Profiler } = require("devtools/server/performance/profiler"); +const { actorBridgeWithSpec } = require("devtools/server/actors/common"); +const { profilerSpec } = require("devtools/shared/specs/profiler"); + +loader.lazyRequireGetter(this, "events", "sdk/event/core"); + +/** + * This actor wraps the Profiler module at devtools/server/performance/profiler.js + * and provides RDP definitions. + * + * @see devtools/server/performance/profiler.js for documentation. + */ +var ProfilerActor = exports.ProfilerActor = ActorClassWithSpec(profilerSpec, { + initialize: function (conn) { + Actor.prototype.initialize.call(this, conn); + this._onProfilerEvent = this._onProfilerEvent.bind(this); + + this.bridge = new Profiler(); + events.on(this.bridge, "*", this._onProfilerEvent); + }, + + /** + * `disconnect` method required to call destroy, since this + * actor is not managed by a parent actor. + */ + disconnect: function () { + this.destroy(); + }, + + destroy: function () { + events.off(this.bridge, "*", this._onProfilerEvent); + this.bridge.destroy(); + Actor.prototype.destroy.call(this); + }, + + startProfiler: actorBridgeWithSpec("start"), + stopProfiler: actorBridgeWithSpec("stop"), + getProfile: actorBridgeWithSpec("getProfile"), + getFeatures: actorBridgeWithSpec("getFeatures"), + getBufferInfo: actorBridgeWithSpec("getBufferInfo"), + getStartOptions: actorBridgeWithSpec("getStartOptions"), + isActive: actorBridgeWithSpec("isActive"), + getSharedLibraryInformation: actorBridgeWithSpec("getSharedLibraryInformation"), + registerEventNotifications: actorBridgeWithSpec("registerEventNotifications"), + unregisterEventNotifications: actorBridgeWithSpec("unregisterEventNotifications"), + setProfilerStatusInterval: actorBridgeWithSpec("setProfilerStatusInterval"), + + /** + * Pipe events from Profiler module to this actor. + */ + _onProfilerEvent: function (eventName, ...data) { + events.emit(this, eventName, ...data); + }, +}); diff --git a/devtools/server/actors/promises.js b/devtools/server/actors/promises.js new file mode 100644 index 000000000..a9a56219d --- /dev/null +++ b/devtools/server/actors/promises.js @@ -0,0 +1,200 @@ +/* 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 protocol = require("devtools/shared/protocol"); +const { promisesSpec } = require("devtools/shared/specs/promises"); +const { expectState, ActorPool } = require("devtools/server/actors/common"); +const { ObjectActor, createValueGrip } = require("devtools/server/actors/object"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +loader.lazyRequireGetter(this, "events", "sdk/event/core"); + +/** + * The Promises Actor provides support for getting the list of live promises and + * observing changes to their settlement state. + */ +var PromisesActor = protocol.ActorClassWithSpec(promisesSpec, { + /** + * @param conn DebuggerServerConnection. + * @param parent TabActor|RootActor + */ + initialize: function (conn, parent) { + protocol.Actor.prototype.initialize.call(this, conn); + + this.conn = conn; + this.parent = parent; + this.state = "detached"; + this._dbg = null; + this._gripDepth = 0; + this._navigationLifetimePool = null; + this._newPromises = null; + this._promisesSettled = null; + + this.objectGrip = this.objectGrip.bind(this); + this._makePromiseEventHandler = this._makePromiseEventHandler.bind(this); + this._onWindowReady = this._onWindowReady.bind(this); + }, + + destroy: function () { + protocol.Actor.prototype.destroy.call(this, this.conn); + + if (this.state === "attached") { + this.detach(); + } + }, + + get dbg() { + if (!this._dbg) { + this._dbg = this.parent.makeDebugger(); + } + return this._dbg; + }, + + /** + * Attach to the PromisesActor. + */ + attach: expectState("detached", function () { + this.dbg.addDebuggees(); + + this._navigationLifetimePool = this._createActorPool(); + this.conn.addActorPool(this._navigationLifetimePool); + + this._newPromises = []; + this._promisesSettled = []; + + this.dbg.findScripts().forEach(s => { + this.parent.sources.createSourceActors(s.source); + }); + + this.dbg.onNewScript = s => { + this.parent.sources.createSourceActors(s.source); + }; + + events.on(this.parent, "window-ready", this._onWindowReady); + + this.state = "attached"; + }, "attaching to the PromisesActor"), + + /** + * Detach from the PromisesActor upon Debugger closing. + */ + detach: expectState("attached", function () { + this.dbg.removeAllDebuggees(); + this.dbg.enabled = false; + this._dbg = null; + this._newPromises = null; + this._promisesSettled = null; + + if (this._navigationLifetimePool) { + this.conn.removeActorPool(this._navigationLifetimePool); + this._navigationLifetimePool = null; + } + + events.off(this.parent, "window-ready", this._onWindowReady); + + this.state = "detached"; + }), + + _createActorPool: function () { + let pool = new ActorPool(this.conn); + pool.objectActors = new WeakMap(); + return pool; + }, + + /** + * Create an ObjectActor for the given Promise object. + * + * @param object promise + * The promise object + * @return object + * An ObjectActor object that wraps the given Promise object + */ + _createObjectActorForPromise: function (promise) { + if (this._navigationLifetimePool.objectActors.has(promise)) { + return this._navigationLifetimePool.objectActors.get(promise); + } + + let actor = new ObjectActor(promise, { + getGripDepth: () => this._gripDepth, + incrementGripDepth: () => this._gripDepth++, + decrementGripDepth: () => this._gripDepth--, + createValueGrip: v => + createValueGrip(v, this._navigationLifetimePool, this.objectGrip), + sources: () => this.parent.sources, + createEnvironmentActor: () => DevToolsUtils.reportException( + "PromisesActor", Error("createEnvironmentActor not yet implemented")), + getGlobalDebugObject: () => DevToolsUtils.reportException( + "PromisesActor", Error("getGlobalDebugObject not yet implemented")), + }); + + this._navigationLifetimePool.addActor(actor); + this._navigationLifetimePool.objectActors.set(promise, actor); + + return actor; + }, + + /** + * Get a grip for the given Promise object. + * + * @param object value + * The Promise object + * @return object + * The grip for the given Promise object + */ + objectGrip: function (value) { + return this._createObjectActorForPromise(value).grip(); + }, + + /** + * Get a list of ObjectActors for all live Promise Objects. + */ + listPromises: function () { + let promises = this.dbg.findObjects({ class: "Promise" }); + + this.dbg.onNewPromise = this._makePromiseEventHandler(this._newPromises, + "new-promises"); + this.dbg.onPromiseSettled = this._makePromiseEventHandler( + this._promisesSettled, "promises-settled"); + + return promises.map(p => this._createObjectActorForPromise(p)); + }, + + /** + * Creates an event handler for onNewPromise that will add the new + * Promise ObjectActor to the array and schedule it to be emitted as a + * batch for the provided event. + * + * @param array array + * The list of Promise ObjectActors to emit + * @param string eventName + * The event name + */ + _makePromiseEventHandler: function (array, eventName) { + return promise => { + let actor = this._createObjectActorForPromise(promise); + let needsScheduling = array.length == 0; + + array.push(actor); + + if (needsScheduling) { + DevToolsUtils.executeSoon(() => { + events.emit(this, eventName, array.splice(0, array.length)); + }); + } + }; + }, + + _onWindowReady: expectState("attached", function ({ isTopLevel }) { + if (!isTopLevel) { + return; + } + + this._navigationLifetimePool.cleanup(); + this.dbg.removeAllDebuggees(); + this.dbg.addDebuggees(); + }) +}); + +exports.PromisesActor = PromisesActor; diff --git a/devtools/server/actors/reflow.js b/devtools/server/actors/reflow.js new file mode 100644 index 000000000..0ebe00207 --- /dev/null +++ b/devtools/server/actors/reflow.js @@ -0,0 +1,514 @@ +/* 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"; + +/** + * About the types of objects in this file: + * + * - ReflowActor: the actor class used for protocol purposes. + * Mostly empty, just gets an instance of LayoutChangesObserver and forwards + * its "reflows" events to clients. + * + * - LayoutChangesObserver: extends Observable and uses the ReflowObserver, to + * track reflows on the page. + * Used by the LayoutActor, but is also exported on the module, so can be used + * by any other actor that needs it. + * + * - Observable: A utility parent class, meant at being extended by classes that + * need a to observe something on the tabActor's windows. + * + * - Dedicated observers: There's only one of them for now: ReflowObserver which + * listens to reflow events via the docshell, + * These dedicated classes are used by the LayoutChangesObserver. + */ + +const {Ci} = require("chrome"); +const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); +const protocol = require("devtools/shared/protocol"); +const {method, Arg} = protocol; +const events = require("sdk/event/core"); +const Heritage = require("sdk/core/heritage"); +const EventEmitter = require("devtools/shared/event-emitter"); +const {reflowSpec} = require("devtools/shared/specs/reflow"); + +/** + * The reflow actor tracks reflows and emits events about them. + */ +var ReflowActor = exports.ReflowActor = protocol.ActorClassWithSpec(reflowSpec, { + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + + this.tabActor = tabActor; + this._onReflow = this._onReflow.bind(this); + this.observer = getLayoutChangesObserver(tabActor); + this._isStarted = false; + }, + + /** + * The reflow actor is the first (and last) in its hierarchy to use + * protocol.js so it doesn't have a parent protocol actor that takes care of + * its lifetime. So it needs a disconnect method to cleanup. + */ + disconnect: function () { + this.destroy(); + }, + + destroy: function () { + this.stop(); + releaseLayoutChangesObserver(this.tabActor); + this.observer = null; + this.tabActor = null; + + protocol.Actor.prototype.destroy.call(this); + }, + + /** + * Start tracking reflows and sending events to clients about them. + * This is a oneway method, do not expect a response and it won't return a + * promise. + */ + start: function () { + if (!this._isStarted) { + this.observer.on("reflows", this._onReflow); + this._isStarted = true; + } + }, + + /** + * Stop tracking reflows and sending events to clients about them. + * This is a oneway method, do not expect a response and it won't return a + * promise. + */ + stop: function () { + if (this._isStarted) { + this.observer.off("reflows", this._onReflow); + this._isStarted = false; + } + }, + + _onReflow: function (event, reflows) { + if (this._isStarted) { + events.emit(this, "reflows", reflows); + } + } +}); + +/** + * Base class for all sorts of observers that need to listen to events on the + * tabActor's windows. + * @param {TabActor} tabActor + * @param {Function} callback Executed everytime the observer observes something + */ +function Observable(tabActor, callback) { + this.tabActor = tabActor; + this.callback = callback; + + this._onWindowReady = this._onWindowReady.bind(this); + this._onWindowDestroyed = this._onWindowDestroyed.bind(this); + + events.on(this.tabActor, "window-ready", this._onWindowReady); + events.on(this.tabActor, "window-destroyed", this._onWindowDestroyed); +} + +Observable.prototype = { + /** + * Is the observer currently observing + */ + isObserving: false, + + /** + * Stop observing and detroy this observer instance + */ + destroy: function () { + if (this.isDestroyed) { + return; + } + this.isDestroyed = true; + + this.stop(); + + events.off(this.tabActor, "window-ready", this._onWindowReady); + events.off(this.tabActor, "window-destroyed", this._onWindowDestroyed); + + this.callback = null; + this.tabActor = null; + }, + + /** + * Start observing whatever it is this observer is supposed to observe + */ + start: function () { + if (this.isObserving) { + return; + } + this.isObserving = true; + + this._startListeners(this.tabActor.windows); + }, + + /** + * Stop observing + */ + stop: function () { + if (!this.isObserving) { + return; + } + this.isObserving = false; + + if (this.tabActor.attached && this.tabActor.docShell) { + // It's only worth stopping if the tabActor is still attached + this._stopListeners(this.tabActor.windows); + } + }, + + _onWindowReady: function ({window}) { + if (this.isObserving) { + this._startListeners([window]); + } + }, + + _onWindowDestroyed: function ({window}) { + if (this.isObserving) { + this._stopListeners([window]); + } + }, + + _startListeners: function (windows) { + // To be implemented by sub-classes. + }, + + _stopListeners: function (windows) { + // To be implemented by sub-classes. + }, + + /** + * To be called by sub-classes when something has been observed + */ + notifyCallback: function (...args) { + this.isObserving && this.callback && this.callback.apply(null, args); + } +}; + +/** + * The LayouChangesObserver will observe reflows as soon as it is started. + * Some devtools actors may cause reflows and it may be wanted to "hide" these + * reflows from the LayouChangesObserver consumers. + * If this is the case, such actors should require this module and use this + * global function to turn the ignore mode on and off temporarily. + * + * Note that if a node is provided, it will be used to force a sync reflow to + * make sure all reflows which occurred before switching the mode on or off are + * either observed or ignored depending on the current mode. + * + * @param {Boolean} ignore + * @param {DOMNode} syncReflowNode The node to use to force a sync reflow + */ +var gIgnoreLayoutChanges = false; +exports.setIgnoreLayoutChanges = function (ignore, syncReflowNode) { + if (syncReflowNode) { + let forceSyncReflow = syncReflowNode.offsetWidth; + } + gIgnoreLayoutChanges = ignore; +}; + +/** + * The LayoutChangesObserver class is instantiated only once per given tab + * and is used to track reflows and dom and style changes in that tab. + * The LayoutActor uses this class to send reflow events to its clients. + * + * This class isn't exported on the module because it shouldn't be instantiated + * to avoid creating several instances per tabs. + * Use `getLayoutChangesObserver(tabActor)` + * and `releaseLayoutChangesObserver(tabActor)` + * which are exported to get and release instances. + * + * The observer loops every EVENT_BATCHING_DELAY ms and checks if layout changes + * have happened since the last loop iteration. If there are, it sends the + * corresponding events: + * + * - "reflows", with an array of all the reflows that occured, + * - "resizes", with an array of all the resizes that occured, + * + * @param {TabActor} tabActor + */ +function LayoutChangesObserver(tabActor) { + this.tabActor = tabActor; + + this._startEventLoop = this._startEventLoop.bind(this); + this._onReflow = this._onReflow.bind(this); + this._onResize = this._onResize.bind(this); + + // Creating the various observers we're going to need + // For now, just the reflow observer, but later we can add markupMutation, + // styleSheetChanges and styleRuleChanges + this.reflowObserver = new ReflowObserver(this.tabActor, this._onReflow); + this.resizeObserver = new WindowResizeObserver(this.tabActor, this._onResize); + + EventEmitter.decorate(this); +} + +exports.LayoutChangesObserver = LayoutChangesObserver; + +LayoutChangesObserver.prototype = { + /** + * How long does this observer waits before emitting batched events. + * The lower the value, the more event packets will be sent to clients, + * potentially impacting performance. + * The higher the value, the more time we'll wait, this is better for + * performance but has an effect on how soon changes are shown in the toolbox. + */ + EVENT_BATCHING_DELAY: 300, + + /** + * Destroying this instance of LayoutChangesObserver will stop the batched + * events from being sent. + */ + destroy: function () { + this.isObserving = false; + + this.reflowObserver.destroy(); + this.reflows = null; + + this.resizeObserver.destroy(); + this.hasResized = false; + + this.tabActor = null; + }, + + start: function () { + if (this.isObserving) { + return; + } + this.isObserving = true; + + this.reflows = []; + this.hasResized = false; + + this._startEventLoop(); + + this.reflowObserver.start(); + this.resizeObserver.start(); + }, + + stop: function () { + if (!this.isObserving) { + return; + } + this.isObserving = false; + + this._stopEventLoop(); + + this.reflows = []; + this.hasResized = false; + + this.reflowObserver.stop(); + this.resizeObserver.stop(); + }, + + /** + * Start the event loop, which regularly checks if there are any observer + * events to be sent as batched events + * Calls itself in a loop. + */ + _startEventLoop: function () { + // Avoid emitting events if the tabActor has been detached (may happen + // during shutdown) + if (!this.tabActor || !this.tabActor.attached) { + return; + } + + // Send any reflows we have + if (this.reflows && this.reflows.length) { + this.emit("reflows", this.reflows); + this.reflows = []; + } + + // Send any resizes we have + if (this.hasResized) { + this.emit("resize"); + this.hasResized = false; + } + + this.eventLoopTimer = this._setTimeout(this._startEventLoop, + this.EVENT_BATCHING_DELAY); + }, + + _stopEventLoop: function () { + this._clearTimeout(this.eventLoopTimer); + }, + + // Exposing set/clearTimeout here to let tests override them if needed + _setTimeout: function (cb, ms) { + return setTimeout(cb, ms); + }, + _clearTimeout: function (t) { + return clearTimeout(t); + }, + + /** + * Executed whenever a reflow is observed. Only stacks the reflow in the + * reflows array. + * The EVENT_BATCHING_DELAY loop will take care of it later. + * @param {Number} start When the reflow started + * @param {Number} end When the reflow ended + * @param {Boolean} isInterruptible + */ + _onReflow: function (start, end, isInterruptible) { + if (gIgnoreLayoutChanges) { + return; + } + + // XXX: when/if bug 997092 gets fixed, we will be able to know which + // elements have been reflowed, which would be a nice thing to add here. + this.reflows.push({ + start: start, + end: end, + isInterruptible: isInterruptible + }); + }, + + /** + * Executed whenever a resize is observed. Only store a flag saying that a + * resize occured. + * The EVENT_BATCHING_DELAY loop will take care of it later. + */ + _onResize: function () { + if (gIgnoreLayoutChanges) { + return; + } + + this.hasResized = true; + } +}; + +/** + * Get a LayoutChangesObserver instance for a given window. This function makes + * sure there is only one instance per window. + * @param {TabActor} tabActor + * @return {LayoutChangesObserver} + */ +var observedWindows = new Map(); +function getLayoutChangesObserver(tabActor) { + let observerData = observedWindows.get(tabActor); + if (observerData) { + observerData.refCounting ++; + return observerData.observer; + } + + let obs = new LayoutChangesObserver(tabActor); + observedWindows.set(tabActor, { + observer: obs, + // counting references allows to stop the observer when no tabActor owns an + // instance. + refCounting: 1 + }); + obs.start(); + return obs; +} +exports.getLayoutChangesObserver = getLayoutChangesObserver; + +/** + * Release a LayoutChangesObserver instance that was retrieved by + * getLayoutChangesObserver. This is required to ensure the tabActor reference + * is removed and the observer is eventually stopped and destroyed. + * @param {TabActor} tabActor + */ +function releaseLayoutChangesObserver(tabActor) { + let observerData = observedWindows.get(tabActor); + if (!observerData) { + return; + } + + observerData.refCounting --; + if (!observerData.refCounting) { + observerData.observer.destroy(); + observedWindows.delete(tabActor); + } +} +exports.releaseLayoutChangesObserver = releaseLayoutChangesObserver; + +/** + * Reports any reflow that occurs in the tabActor's docshells. + * @extends Observable + * @param {TabActor} tabActor + * @param {Function} callback Executed everytime a reflow occurs + */ +function ReflowObserver(tabActor, callback) { + Observable.call(this, tabActor, callback); +} + +ReflowObserver.prototype = Heritage.extend(Observable.prototype, { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIReflowObserver, + Ci.nsISupportsWeakReference]), + + _startListeners: function (windows) { + for (let window of windows) { + let docshell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + docshell.addWeakReflowObserver(this); + } + }, + + _stopListeners: function (windows) { + for (let window of windows) { + try { + let docshell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + docshell.removeWeakReflowObserver(this); + } catch (e) { + // Corner cases where a global has already been freed may happen, in + // which case, no need to remove the observer. + } + } + }, + + reflow: function (start, end) { + this.notifyCallback(start, end, false); + }, + + reflowInterruptible: function (start, end) { + this.notifyCallback(start, end, true); + } +}); + +/** + * Reports window resize events on the tabActor's windows. + * @extends Observable + * @param {TabActor} tabActor + * @param {Function} callback Executed everytime a resize occurs + */ +function WindowResizeObserver(tabActor, callback) { + Observable.call(this, tabActor, callback); + this.onResize = this.onResize.bind(this); +} + +WindowResizeObserver.prototype = Heritage.extend(Observable.prototype, { + _startListeners: function () { + this.listenerTarget.addEventListener("resize", this.onResize); + }, + + _stopListeners: function () { + this.listenerTarget.removeEventListener("resize", this.onResize); + }, + + onResize: function () { + this.notifyCallback(); + }, + + get listenerTarget() { + // For the rootActor, return its window. + if (this.tabActor.isRootActor) { + return this.tabActor.window; + } + + // Otherwise, get the tabActor's chromeEventHandler. + return this.tabActor.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell) + .chromeEventHandler; + } +}); diff --git a/devtools/server/actors/root.js b/devtools/server/actors/root.js new file mode 100644 index 000000000..b6f8c0ee4 --- /dev/null +++ b/devtools/server/actors/root.js @@ -0,0 +1,535 @@ +/* -*- 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 { Cc, Ci, Cu } = require("chrome"); +const Services = require("Services"); +const { ActorPool, appendExtraActors, createExtraActors } = require("devtools/server/actors/common"); +const { DebuggerServer } = require("devtools/server/main"); + +loader.lazyGetter(this, "ppmm", () => { + return Cc["@mozilla.org/parentprocessmessagemanager;1"].getService(Ci.nsIMessageBroadcaster); +}); + +/* Root actor for the remote debugging protocol. */ + +/** + * Create a remote debugging protocol root actor. + * + * @param aConnection + * The DebuggerServerConnection whose root actor we are constructing. + * + * @param aParameters + * The properties of |aParameters| provide backing objects for the root + * actor's requests; if a given property is omitted from |aParameters|, the + * root actor won't implement the corresponding requests or notifications. + * Supported properties: + * + * - tabList: a live list (see below) of tab actors. If present, the + * new root actor supports the 'listTabs' request, providing the live + * list's elements as its tab actors, and sending 'tabListChanged' + * notifications when the live list's contents change. One actor in + * this list must have a true '.selected' property. + * + * - addonList: a live list (see below) of addon actors. If present, the + * new root actor supports the 'listAddons' request, providing the live + * list's elements as its addon actors, and sending 'addonListchanged' + * notifications when the live list's contents change. + * + * - globalActorFactories: an object |A| describing further actors to + * attach to the 'listTabs' reply. This is the type accumulated by + * DebuggerServer.addGlobalActor. For each own property |P| of |A|, + * the root actor adds a property named |P| to the 'listTabs' + * reply whose value is the name of an actor constructed by + * |A[P]|. + * + * - onShutdown: a function to call when the root actor is disconnected. + * + * Instance properties: + * + * - applicationType: the string the root actor will include as the + * "applicationType" property in the greeting packet. By default, this + * is "browser". + * + * Live lists: + * + * A "live list", as used for the |tabList|, is an object that presents a + * list of actors, and also notifies its clients of changes to the list. A + * live list's interface is two properties: + * + * - getList: a method that returns a promise to the contents of the list. + * + * - onListChanged: a handler called, with no arguments, when the set of + * values the iterator would produce has changed since the last + * time 'iterator' was called. This may only be set to null or a + * callable value (one for which the typeof operator returns + * 'function'). (Note that the live list will not call the + * onListChanged handler until the list has been iterated over + * once; if nobody's seen the list in the first place, nobody + * should care if its contents have changed!) + * + * When the list changes, the list implementation should ensure that any + * actors yielded in previous iterations whose referents (tabs) still exist + * get yielded again in subsequent iterations. If the underlying referent + * is the same, the same actor should be presented for it. + * + * The root actor registers an 'onListChanged' handler on the appropriate + * list when it may need to send the client 'tabListChanged' notifications, + * and is careful to remove the handler whenever it does not need to send + * such notifications (including when it is disconnected). This means that + * live list implementations can use the state of the handler property (set + * or null) to install and remove observers and event listeners. + * + * Note that, as the only way for the root actor to see the members of the + * live list is to begin an iteration over the list, the live list need not + * actually produce any actors until they are reached in the course of + * iteration: alliterative lazy live lists. + */ +function RootActor(aConnection, aParameters) { + this.conn = aConnection; + this._parameters = aParameters; + this._onTabListChanged = this.onTabListChanged.bind(this); + this._onAddonListChanged = this.onAddonListChanged.bind(this); + this._onWorkerListChanged = this.onWorkerListChanged.bind(this); + this._onServiceWorkerRegistrationListChanged = this.onServiceWorkerRegistrationListChanged.bind(this); + this._onProcessListChanged = this.onProcessListChanged.bind(this); + this._extraActors = {}; + + this._globalActorPool = new ActorPool(this.conn); + this.conn.addActorPool(this._globalActorPool); + + this._chromeActor = null; +} + +RootActor.prototype = { + constructor: RootActor, + applicationType: "browser", + + traits: { + sources: true, + // Whether the inspector actor allows modifying outer HTML. + editOuterHTML: true, + // Whether the inspector actor allows modifying innerHTML and inserting + // adjacent HTML. + pasteHTML: true, + // Whether the server-side highlighter actor exists and can be used to + // remotely highlight nodes (see server/actors/highlighters.js) + highlightable: true, + // Which custom highlighter does the server-side highlighter actor supports? + // (see server/actors/highlighters.js) + customHighlighters: true, + // Whether the inspector actor implements the getImageDataFromURL + // method that returns data-uris for image URLs. This is used for image + // tooltips for instance + urlToImageDataResolver: true, + networkMonitor: true, + // Whether the storage inspector actor to inspect cookies, etc. + storageInspector: true, + // Whether storage inspector is read only + storageInspectorReadOnly: true, + // Whether conditional breakpoints are supported + conditionalBreakpoints: true, + // Whether the server supports full source actors (breakpoints on + // eval scripts, etc) + debuggerSourceActors: true, + bulk: true, + // Whether the style rule actor implements the modifySelector method + // that modifies the rule's selector + selectorEditable: true, + // Whether the page style actor implements the addNewRule method that + // adds new rules to the page + addNewRule: true, + // Whether the dom node actor implements the getUniqueSelector method + getUniqueSelector: true, + // Whether the director scripts are supported + directorScripts: true, + // Whether the debugger server supports + // blackboxing/pretty-printing (not supported in Fever Dream yet) + noBlackBoxing: false, + noPrettyPrinting: false, + // Whether the page style actor implements the getUsedFontFaces method + // that returns the font faces used on a node + getUsedFontFaces: true, + // Trait added in Gecko 38, indicating that all features necessary for + // grabbing allocations from the MemoryActor are available for the performance tool + memoryActorAllocations: true, + // Added in Gecko 40, indicating that the backend isn't stupid about + // sending resumption packets on tab navigation. + noNeedToFakeResumptionOnNavigation: true, + // Added in Firefox 40. Indicates that the backend supports registering custom + // commands through the WebConsoleCommands API. + webConsoleCommands: true, + // Whether root actor exposes tab actors + // if allowChromeProcess is true, you can fetch a ChromeActor instance + // to debug chrome and any non-content ressource via getProcess request + // if allocChromeProcess is defined, but not true, it means that root actor + // no longer expose tab actors, but also that getProcess forbids + // exposing actors for security reasons + get allowChromeProcess() { + return DebuggerServer.allowChromeProcess; + }, + // Whether or not `getProfile()` supports specifying a `startTime` + // and `endTime` to filter out samples. Fx40+ + profilerDataFilterable: true, + // Whether or not the MemoryActor's heap snapshot abilities are + // fully equipped to handle heap snapshots for the memory tool. Fx44+ + heapSnapshots: true, + // Whether or not the timeline actor can emit DOMContentLoaded and Load + // markers, currently in use by the network monitor. Fx45+ + documentLoadingMarkers: true + }, + + /** + * Return a 'hello' packet as specified by the Remote Debugging Protocol. + */ + sayHello: function () { + return { + from: this.actorID, + applicationType: this.applicationType, + /* This is not in the spec, but it's used by tests. */ + testConnectionPrefix: this.conn.prefix, + traits: this.traits + }; + }, + + forwardingCancelled: function (prefix) { + return { + from: this.actorID, + type: "forwardingCancelled", + prefix, + }; + }, + + /** + * Disconnects the actor from the browser window. + */ + disconnect: function () { + /* Tell the live lists we aren't watching any more. */ + if (this._parameters.tabList) { + this._parameters.tabList.onListChanged = null; + } + if (this._parameters.addonList) { + this._parameters.addonList.onListChanged = null; + } + if (this._parameters.workerList) { + this._parameters.workerList.onListChanged = null; + } + if (this._parameters.serviceWorkerRegistrationList) { + this._parameters.serviceWorkerRegistrationList.onListChanged = null; + } + if (typeof this._parameters.onShutdown === "function") { + this._parameters.onShutdown(); + } + this._extraActors = null; + this.conn = null; + this._tabActorPool = null; + this._globalActorPool = null; + this._parameters = null; + this._chromeActor = null; + }, + + /* The 'listTabs' request and the 'tabListChanged' notification. */ + + /** + * Handles the listTabs request. The actors will survive until at least + * the next listTabs request. + */ + onListTabs: function () { + let tabList = this._parameters.tabList; + if (!tabList) { + return { from: this.actorID, error: "noTabs", + message: "This root actor has no browser tabs." }; + } + + /* + * Walk the tab list, accumulating the array of tab actors for the + * reply, and moving all the actors to a new ActorPool. We'll + * replace the old tab actor pool with the one we build here, thus + * retiring any actors that didn't get listed again, and preparing any + * new actors to receive packets. + */ + let newActorPool = new ActorPool(this.conn); + let tabActorList = []; + let selected; + return tabList.getList().then((tabActors) => { + for (let tabActor of tabActors) { + if (tabActor.selected) { + selected = tabActorList.length; + } + tabActor.parentID = this.actorID; + newActorPool.addActor(tabActor); + tabActorList.push(tabActor); + } + /* DebuggerServer.addGlobalActor support: create actors. */ + if (!this._globalActorPool) { + this._globalActorPool = new ActorPool(this.conn); + this.conn.addActorPool(this._globalActorPool); + } + this._createExtraActors(this._parameters.globalActorFactories, this._globalActorPool); + /* + * Drop the old actorID -> actor map. Actors that still mattered were + * added to the new map; others will go away. + */ + if (this._tabActorPool) { + this.conn.removeActorPool(this._tabActorPool); + } + this._tabActorPool = newActorPool; + this.conn.addActorPool(this._tabActorPool); + + let reply = { + "from": this.actorID, + "selected": selected || 0, + "tabs": tabActorList.map(actor => actor.form()) + }; + + /* If a root window is accessible, include its URL. */ + if (this.url) { + reply.url = this.url; + } + + /* DebuggerServer.addGlobalActor support: name actors in 'listTabs' reply. */ + this._appendExtraActors(reply); + + /* + * Now that we're actually going to report the contents of tabList to + * the client, we're responsible for letting the client know if it + * changes. + */ + tabList.onListChanged = this._onTabListChanged; + + return reply; + }); + }, + + onGetTab: function (options) { + let tabList = this._parameters.tabList; + if (!tabList) { + return { error: "noTabs", + message: "This root actor has no browser tabs." }; + } + if (!this._tabActorPool) { + this._tabActorPool = new ActorPool(this.conn); + this.conn.addActorPool(this._tabActorPool); + } + return tabList.getTab(options) + .then(tabActor => { + tabActor.parentID = this.actorID; + this._tabActorPool.addActor(tabActor); + + return { tab: tabActor.form() }; + }, error => { + if (error.error) { + // Pipe expected errors as-is to the client + return error; + } else { + return { error: "noTab", + message: "Unexpected error while calling getTab(): " + error }; + } + }); + }, + + onTabListChanged: function () { + this.conn.send({ from: this.actorID, type:"tabListChanged" }); + /* It's a one-shot notification; no need to watch any more. */ + this._parameters.tabList.onListChanged = null; + }, + + onListAddons: function () { + let addonList = this._parameters.addonList; + if (!addonList) { + return { from: this.actorID, error: "noAddons", + message: "This root actor has no browser addons." }; + } + + return addonList.getList().then((addonActors) => { + let addonActorPool = new ActorPool(this.conn); + for (let addonActor of addonActors) { + addonActorPool.addActor(addonActor); + } + + if (this._addonActorPool) { + this.conn.removeActorPool(this._addonActorPool); + } + this._addonActorPool = addonActorPool; + this.conn.addActorPool(this._addonActorPool); + + addonList.onListChanged = this._onAddonListChanged; + + return { + "from": this.actorID, + "addons": addonActors.map(addonActor => addonActor.form()) + }; + }); + }, + + onAddonListChanged: function () { + this.conn.send({ from: this.actorID, type: "addonListChanged" }); + this._parameters.addonList.onListChanged = null; + }, + + onListWorkers: function () { + let workerList = this._parameters.workerList; + if (!workerList) { + return { from: this.actorID, error: "noWorkers", + message: "This root actor has no workers." }; + } + + return workerList.getList().then(actors => { + let pool = new ActorPool(this.conn); + for (let actor of actors) { + pool.addActor(actor); + } + + this.conn.removeActorPool(this._workerActorPool); + this._workerActorPool = pool; + this.conn.addActorPool(this._workerActorPool); + + workerList.onListChanged = this._onWorkerListChanged; + + return { + "from": this.actorID, + "workers": actors.map(actor => actor.form()) + }; + }); + }, + + onWorkerListChanged: function () { + this.conn.send({ from: this.actorID, type: "workerListChanged" }); + this._parameters.workerList.onListChanged = null; + }, + + onListServiceWorkerRegistrations: function () { + let registrationList = this._parameters.serviceWorkerRegistrationList; + if (!registrationList) { + return { from: this.actorID, error: "noServiceWorkerRegistrations", + message: "This root actor has no service worker registrations." }; + } + + return registrationList.getList().then(actors => { + let pool = new ActorPool(this.conn); + for (let actor of actors) { + pool.addActor(actor); + } + + this.conn.removeActorPool(this._serviceWorkerRegistrationActorPool); + this._serviceWorkerRegistrationActorPool = pool; + this.conn.addActorPool(this._serviceWorkerRegistrationActorPool); + + registrationList.onListChanged = this._onServiceWorkerRegistrationListChanged; + + return { + "from": this.actorID, + "registrations": actors.map(actor => actor.form()) + }; + }); + }, + + onServiceWorkerRegistrationListChanged: function () { + this.conn.send({ from: this.actorID, type: "serviceWorkerRegistrationListChanged" }); + this._parameters.serviceWorkerRegistrationList.onListChanged = null; + }, + + onListProcesses: function () { + let { processList } = this._parameters; + if (!processList) { + return { from: this.actorID, error: "noProcesses", + message: "This root actor has no processes." }; + } + processList.onListChanged = this._onProcessListChanged; + return { + processes: processList.getList() + }; + }, + + onProcessListChanged: function () { + this.conn.send({ from: this.actorID, type: "processListChanged" }); + this._parameters.processList.onListChanged = null; + }, + + onGetProcess: function (aRequest) { + if (!DebuggerServer.allowChromeProcess) { + return { error: "forbidden", + message: "You are not allowed to debug chrome." }; + } + if (("id" in aRequest) && typeof (aRequest.id) != "number") { + return { error: "wrongParameter", + message: "getProcess requires a valid `id` attribute." }; + } + // If the request doesn't contains id parameter or id is 0 + // (id == 0, based on onListProcesses implementation) + if ((!("id" in aRequest)) || aRequest.id === 0) { + if (!this._chromeActor) { + // Create a ChromeActor for the parent process + let { ChromeActor } = require("devtools/server/actors/chrome"); + this._chromeActor = new ChromeActor(this.conn); + this._globalActorPool.addActor(this._chromeActor); + } + + return { form: this._chromeActor.form() }; + } else { + let mm = ppmm.getChildAt(aRequest.id); + if (!mm) { + return { error: "noProcess", + message: "There is no process with id '" + aRequest.id + "'." }; + } + return DebuggerServer.connectToContent(this.conn, mm) + .then(form => ({ form })); + } + }, + + /* This is not in the spec, but it's used by tests. */ + onEcho: function (aRequest) { + /* + * Request packets are frozen. Copy aRequest, so that + * DebuggerServerConnection.onPacket can attach a 'from' property. + */ + return Cu.cloneInto(aRequest, {}); + }, + + onProtocolDescription: function () { + return require("devtools/shared/protocol").dumpProtocolSpec(); + }, + + /* Support for DebuggerServer.addGlobalActor. */ + _createExtraActors: createExtraActors, + _appendExtraActors: appendExtraActors, + + /** + * Remove the extra actor (added by DebuggerServer.addGlobalActor or + * DebuggerServer.addTabActor) name |aName|. + */ + removeActorByName: function (aName) { + if (aName in this._extraActors) { + const actor = this._extraActors[aName]; + if (this._globalActorPool.has(actor)) { + this._globalActorPool.removeActor(actor); + } + if (this._tabActorPool) { + // Iterate over TabActor instances to also remove tab actors + // created during listTabs for each document. + this._tabActorPool.forEach(tab => { + tab.removeActorByName(aName); + }); + } + delete this._extraActors[aName]; + } + } +}; + +RootActor.prototype.requestTypes = { + "listTabs": RootActor.prototype.onListTabs, + "getTab": RootActor.prototype.onGetTab, + "listAddons": RootActor.prototype.onListAddons, + "listWorkers": RootActor.prototype.onListWorkers, + "listServiceWorkerRegistrations": RootActor.prototype.onListServiceWorkerRegistrations, + "listProcesses": RootActor.prototype.onListProcesses, + "getProcess": RootActor.prototype.onGetProcess, + "echo": RootActor.prototype.onEcho, + "protocolDescription": RootActor.prototype.onProtocolDescription +}; + +exports.RootActor = RootActor; diff --git a/devtools/server/actors/script.js b/devtools/server/actors/script.js new file mode 100644 index 000000000..e8e39546c --- /dev/null +++ b/devtools/server/actors/script.js @@ -0,0 +1,2360 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; 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 { Cc, Ci, Cu, Cr, components, ChromeWorker } = require("chrome"); +const { ActorPool, OriginalLocation, GeneratedLocation } = require("devtools/server/actors/common"); +const { BreakpointActor, setBreakpointAtEntryPoints } = require("devtools/server/actors/breakpoint"); +const { EnvironmentActor } = require("devtools/server/actors/environment"); +const { FrameActor } = require("devtools/server/actors/frame"); +const { ObjectActor, createValueGrip, longStringGrip } = require("devtools/server/actors/object"); +const { SourceActor, getSourceURL } = require("devtools/server/actors/source"); +const { DebuggerServer } = require("devtools/server/main"); +const { ActorClassWithSpec } = require("devtools/shared/protocol"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const flags = require("devtools/shared/flags"); +const { assert, dumpn, update, fetch } = DevToolsUtils; +const promise = require("promise"); +const xpcInspector = require("xpcInspector"); +const { DevToolsWorker } = require("devtools/shared/worker/worker"); +const object = require("sdk/util/object"); +const { threadSpec } = require("devtools/shared/specs/script"); + +const { defer, resolve, reject, all } = promise; + +loader.lazyGetter(this, "Debugger", () => { + let Debugger = require("Debugger"); + hackDebugger(Debugger); + return Debugger; +}); +loader.lazyRequireGetter(this, "CssLogic", "devtools/server/css-logic", true); +loader.lazyRequireGetter(this, "events", "sdk/event/core"); +loader.lazyRequireGetter(this, "mapURIToAddonID", "devtools/server/actors/utils/map-uri-to-addon-id"); + +/** + * A BreakpointActorMap is a map from locations to instances of BreakpointActor. + */ +function BreakpointActorMap() { + this._size = 0; + this._actors = {}; +} + +BreakpointActorMap.prototype = { + /** + * Return the number of BreakpointActors in this BreakpointActorMap. + * + * @returns Number + * The number of BreakpointActor in this BreakpointActorMap. + */ + get size() { + return this._size; + }, + + /** + * Generate all BreakpointActors that match the given location in + * this BreakpointActorMap. + * + * @param OriginalLocation location + * The location for which matching BreakpointActors should be generated. + */ + findActors: function* (location = new OriginalLocation()) { + // Fast shortcut for when we know we won't find any actors. Surprisingly + // enough, this speeds up refreshing when there are no breakpoints set by + // about 2x! + if (this.size === 0) { + return; + } + + function* findKeys(object, key) { + if (key !== undefined) { + if (key in object) { + yield key; + } + } + else { + for (let key of Object.keys(object)) { + yield key; + } + } + } + + let query = { + sourceActorID: location.originalSourceActor ? location.originalSourceActor.actorID : undefined, + line: location.originalLine, + }; + + // If location contains a line, assume we are searching for a whole line + // breakpoint, and set begin/endColumn accordingly. Otherwise, we are + // searching for all breakpoints, so begin/endColumn should be left unset. + if (location.originalLine) { + query.beginColumn = location.originalColumn ? location.originalColumn : 0; + query.endColumn = location.originalColumn ? location.originalColumn + 1 : Infinity; + } else { + query.beginColumn = location.originalColumn ? query.originalColumn : undefined; + query.endColumn = location.originalColumn ? query.originalColumn + 1 : undefined; + } + + for (let sourceActorID of findKeys(this._actors, query.sourceActorID)) + for (let line of findKeys(this._actors[sourceActorID], query.line)) + for (let beginColumn of findKeys(this._actors[sourceActorID][line], query.beginColumn)) + for (let endColumn of findKeys(this._actors[sourceActorID][line][beginColumn], query.endColumn)) { + yield this._actors[sourceActorID][line][beginColumn][endColumn]; + } + }, + + /** + * Return the BreakpointActor at the given location in this + * BreakpointActorMap. + * + * @param OriginalLocation location + * The location for which the BreakpointActor should be returned. + * + * @returns BreakpointActor actor + * The BreakpointActor at the given location. + */ + getActor: function (originalLocation) { + for (let actor of this.findActors(originalLocation)) { + return actor; + } + + return null; + }, + + /** + * Set the given BreakpointActor to the given location in this + * BreakpointActorMap. + * + * @param OriginalLocation location + * The location to which the given BreakpointActor should be set. + * + * @param BreakpointActor actor + * The BreakpointActor to be set to the given location. + */ + setActor: function (location, actor) { + let { originalSourceActor, originalLine, originalColumn } = location; + + let sourceActorID = originalSourceActor.actorID; + let line = originalLine; + let beginColumn = originalColumn ? originalColumn : 0; + let endColumn = originalColumn ? originalColumn + 1 : Infinity; + + if (!this._actors[sourceActorID]) { + this._actors[sourceActorID] = []; + } + if (!this._actors[sourceActorID][line]) { + this._actors[sourceActorID][line] = []; + } + if (!this._actors[sourceActorID][line][beginColumn]) { + this._actors[sourceActorID][line][beginColumn] = []; + } + if (!this._actors[sourceActorID][line][beginColumn][endColumn]) { + ++this._size; + } + this._actors[sourceActorID][line][beginColumn][endColumn] = actor; + }, + + /** + * Delete the BreakpointActor from the given location in this + * BreakpointActorMap. + * + * @param OriginalLocation location + * The location from which the BreakpointActor should be deleted. + */ + deleteActor: function (location) { + let { originalSourceActor, originalLine, originalColumn } = location; + + let sourceActorID = originalSourceActor.actorID; + let line = originalLine; + let beginColumn = originalColumn ? originalColumn : 0; + let endColumn = originalColumn ? originalColumn + 1 : Infinity; + + if (this._actors[sourceActorID]) { + if (this._actors[sourceActorID][line]) { + if (this._actors[sourceActorID][line][beginColumn]) { + if (this._actors[sourceActorID][line][beginColumn][endColumn]) { + --this._size; + } + delete this._actors[sourceActorID][line][beginColumn][endColumn]; + if (Object.keys(this._actors[sourceActorID][line][beginColumn]).length === 0) { + delete this._actors[sourceActorID][line][beginColumn]; + } + } + if (Object.keys(this._actors[sourceActorID][line]).length === 0) { + delete this._actors[sourceActorID][line]; + } + } + } + } +}; + +exports.BreakpointActorMap = BreakpointActorMap; + +/** + * Keeps track of persistent sources across reloads and ties different + * source instances to the same actor id so that things like + * breakpoints survive reloads. ThreadSources uses this to force the + * same actorID on a SourceActor. + */ +function SourceActorStore() { + // source identifier --> actor id + this._sourceActorIds = Object.create(null); +} + +SourceActorStore.prototype = { + /** + * Lookup an existing actor id that represents this source, if available. + */ + getReusableActorId: function (aSource, aOriginalUrl) { + let url = this.getUniqueKey(aSource, aOriginalUrl); + if (url && url in this._sourceActorIds) { + return this._sourceActorIds[url]; + } + return null; + }, + + /** + * Update a source with an actorID. + */ + setReusableActorId: function (aSource, aOriginalUrl, actorID) { + let url = this.getUniqueKey(aSource, aOriginalUrl); + if (url) { + this._sourceActorIds[url] = actorID; + } + }, + + /** + * Make a unique URL from a source that identifies it across reloads. + */ + getUniqueKey: function (aSource, aOriginalUrl) { + if (aOriginalUrl) { + // Original source from a sourcemap. + return aOriginalUrl; + } + else { + return getSourceURL(aSource); + } + } +}; + +exports.SourceActorStore = SourceActorStore; + +/** + * Manages pushing event loops and automatically pops and exits them in the + * correct order as they are resolved. + * + * @param ThreadActor thread + * The thread actor instance that owns this EventLoopStack. + * @param DebuggerServerConnection connection + * The remote protocol connection associated with this event loop stack. + * @param Object hooks + * An object with the following properties: + * - url: The URL string of the debuggee we are spinning an event loop + * for. + * - preNest: function called before entering a nested event loop + * - postNest: function called after exiting a nested event loop + */ +function EventLoopStack({ thread, connection, hooks }) { + this._hooks = hooks; + this._thread = thread; + this._connection = connection; +} + +EventLoopStack.prototype = { + /** + * The number of nested event loops on the stack. + */ + get size() { + return xpcInspector.eventLoopNestLevel; + }, + + /** + * The URL of the debuggee who pushed the event loop on top of the stack. + */ + get lastPausedUrl() { + let url = null; + if (this.size > 0) { + try { + url = xpcInspector.lastNestRequestor.url; + } catch (e) { + // The tab's URL getter may throw if the tab is destroyed by the time + // this code runs, but we don't really care at this point. + dumpn(e); + } + } + return url; + }, + + /** + * The DebuggerServerConnection of the debugger who pushed the event loop on + * top of the stack + */ + get lastConnection() { + return xpcInspector.lastNestRequestor._connection; + }, + + /** + * Push a new nested event loop onto the stack. + * + * @returns EventLoop + */ + push: function () { + return new EventLoop({ + thread: this._thread, + connection: this._connection, + hooks: this._hooks + }); + } +}; + +/** + * An object that represents a nested event loop. It is used as the nest + * requestor with nsIJSInspector instances. + * + * @param ThreadActor thread + * The thread actor that is creating this nested event loop. + * @param DebuggerServerConnection connection + * The remote protocol connection associated with this event loop. + * @param Object hooks + * The same hooks object passed into EventLoopStack during its + * initialization. + */ +function EventLoop({ thread, connection, hooks }) { + this._thread = thread; + this._hooks = hooks; + this._connection = connection; + + this.enter = this.enter.bind(this); + this.resolve = this.resolve.bind(this); +} + +EventLoop.prototype = { + entered: false, + resolved: false, + get url() { return this._hooks.url; }, + + /** + * Enter this nested event loop. + */ + enter: function () { + let nestData = this._hooks.preNest + ? this._hooks.preNest() + : null; + + this.entered = true; + xpcInspector.enterNestedEventLoop(this); + + // Keep exiting nested event loops while the last requestor is resolved. + if (xpcInspector.eventLoopNestLevel > 0) { + const { resolved } = xpcInspector.lastNestRequestor; + if (resolved) { + xpcInspector.exitNestedEventLoop(); + } + } + + if (this._hooks.postNest) { + this._hooks.postNest(nestData); + } + }, + + /** + * Resolve this nested event loop. + * + * @returns boolean + * True if we exited this nested event loop because it was on top of + * the stack, false if there is another nested event loop above this + * one that hasn't resolved yet. + */ + resolve: function () { + if (!this.entered) { + throw new Error("Can't resolve an event loop before it has been entered!"); + } + if (this.resolved) { + throw new Error("Already resolved this nested event loop!"); + } + this.resolved = true; + if (this === xpcInspector.lastNestRequestor) { + xpcInspector.exitNestedEventLoop(); + return true; + } + return false; + }, +}; + +/** + * JSD2 actors. + */ + +/** + * Creates a ThreadActor. + * + * ThreadActors manage a JSInspector object and manage execution/inspection + * of debuggees. + * + * @param aParent object + * This |ThreadActor|'s parent actor. It must implement the following + * properties: + * - url: The URL string of the debuggee. + * - window: The global window object. + * - preNest: Function called before entering a nested event loop. + * - postNest: Function called after exiting a nested event loop. + * - makeDebugger: A function that takes no arguments and instantiates + * a Debugger that manages its globals on its own. + * @param aGlobal object [optional] + * An optional (for content debugging only) reference to the content + * window. + */ +const ThreadActor = ActorClassWithSpec(threadSpec, { + initialize: function (aParent, aGlobal) { + this._state = "detached"; + this._frameActors = []; + this._parent = aParent; + this._dbg = null; + this._gripDepth = 0; + this._threadLifetimePool = null; + this._tabClosed = false; + this._scripts = null; + this._pauseOnDOMEvents = null; + + this._options = { + useSourceMaps: false, + autoBlackBox: false + }; + + this.breakpointActorMap = new BreakpointActorMap(); + this.sourceActorStore = new SourceActorStore(); + + this._debuggerSourcesSeen = null; + + // A map of actorID -> actor for breakpoints created and managed by the + // server. + this._hiddenBreakpoints = new Map(); + + this.global = aGlobal; + + this._allEventsListener = this._allEventsListener.bind(this); + this.onNewGlobal = this.onNewGlobal.bind(this); + this.onSourceEvent = this.onSourceEvent.bind(this); + this.uncaughtExceptionHook = this.uncaughtExceptionHook.bind(this); + this.onDebuggerStatement = this.onDebuggerStatement.bind(this); + this.onNewScript = this.onNewScript.bind(this); + this.objectGrip = this.objectGrip.bind(this); + this.pauseObjectGrip = this.pauseObjectGrip.bind(this); + this._onWindowReady = this._onWindowReady.bind(this); + events.on(this._parent, "window-ready", this._onWindowReady); + // Set a wrappedJSObject property so |this| can be sent via the observer svc + // for the xpcshell harness. + this.wrappedJSObject = this; + }, + + // Used by the ObjectActor to keep track of the depth of grip() calls. + _gripDepth: null, + + get dbg() { + if (!this._dbg) { + this._dbg = this._parent.makeDebugger(); + this._dbg.uncaughtExceptionHook = this.uncaughtExceptionHook; + this._dbg.onDebuggerStatement = this.onDebuggerStatement; + this._dbg.onNewScript = this.onNewScript; + this._dbg.on("newGlobal", this.onNewGlobal); + // Keep the debugger disabled until a client attaches. + this._dbg.enabled = this._state != "detached"; + } + return this._dbg; + }, + + get globalDebugObject() { + if (!this._parent.window) { + return null; + } + return this.dbg.makeGlobalObjectReference(this._parent.window); + }, + + get state() { + return this._state; + }, + + get attached() { + return this.state == "attached" || + this.state == "running" || + this.state == "paused"; + }, + + get threadLifetimePool() { + if (!this._threadLifetimePool) { + this._threadLifetimePool = new ActorPool(this.conn); + this.conn.addActorPool(this._threadLifetimePool); + this._threadLifetimePool.objectActors = new WeakMap(); + } + return this._threadLifetimePool; + }, + + get sources() { + return this._parent.sources; + }, + + get youngestFrame() { + if (this.state != "paused") { + return null; + } + return this.dbg.getNewestFrame(); + }, + + _prettyPrintWorker: null, + get prettyPrintWorker() { + if (!this._prettyPrintWorker) { + this._prettyPrintWorker = new DevToolsWorker( + "resource://devtools/server/actors/pretty-print-worker.js", + { name: "pretty-print", + verbose: flags.wantLogging } + ); + } + return this._prettyPrintWorker; + }, + + /** + * Keep track of all of the nested event loops we use to pause the debuggee + * when we hit a breakpoint/debugger statement/etc in one place so we can + * resolve them when we get resume packets. We have more than one (and keep + * them in a stack) because we can pause within client evals. + */ + _threadPauseEventLoops: null, + _pushThreadPause: function () { + if (!this._threadPauseEventLoops) { + this._threadPauseEventLoops = []; + } + const eventLoop = this._nestedEventLoops.push(); + this._threadPauseEventLoops.push(eventLoop); + eventLoop.enter(); + }, + _popThreadPause: function () { + const eventLoop = this._threadPauseEventLoops.pop(); + assert(eventLoop, "Should have an event loop."); + eventLoop.resolve(); + }, + + /** + * Remove all debuggees and clear out the thread's sources. + */ + clearDebuggees: function () { + if (this._dbg) { + this.dbg.removeAllDebuggees(); + } + this._sources = null; + this._scripts = null; + }, + + /** + * Listener for our |Debugger|'s "newGlobal" event. + */ + onNewGlobal: function (aGlobal) { + // Notify the client. + this.conn.send({ + from: this.actorID, + type: "newGlobal", + // TODO: after bug 801084 lands see if we need to JSONify this. + hostAnnotations: aGlobal.hostAnnotations + }); + }, + + disconnect: function () { + dumpn("in ThreadActor.prototype.disconnect"); + if (this._state == "paused") { + this.onResume(); + } + + // Blow away our source actor ID store because those IDs are only + // valid for this connection. This is ok because we never keep + // things like breakpoints across connections. + this._sourceActorStore = null; + + events.off(this._parent, "window-ready", this._onWindowReady); + this.sources.off("newSource", this.onSourceEvent); + this.sources.off("updatedSource", this.onSourceEvent); + this.clearDebuggees(); + this.conn.removeActorPool(this._threadLifetimePool); + this._threadLifetimePool = null; + + if (this._prettyPrintWorker) { + this._prettyPrintWorker.destroy(); + this._prettyPrintWorker = null; + } + + if (!this._dbg) { + return; + } + this._dbg.enabled = false; + this._dbg = null; + }, + + /** + * Disconnect the debugger and put the actor in the exited state. + */ + exit: function () { + this.disconnect(); + this._state = "exited"; + }, + + // Request handlers + onAttach: function (aRequest) { + if (this.state === "exited") { + return { type: "exited" }; + } + + if (this.state !== "detached") { + return { error: "wrongState", + message: "Current state is " + this.state }; + } + + this._state = "attached"; + this._debuggerSourcesSeen = new WeakSet(); + + Object.assign(this._options, aRequest.options || {}); + this.sources.setOptions(this._options); + this.sources.on("newSource", this.onSourceEvent); + this.sources.on("updatedSource", this.onSourceEvent); + + // Initialize an event loop stack. This can't be done in the constructor, + // because this.conn is not yet initialized by the actor pool at that time. + this._nestedEventLoops = new EventLoopStack({ + hooks: this._parent, + connection: this.conn, + thread: this + }); + + this.dbg.addDebuggees(); + this.dbg.enabled = true; + try { + // Put ourselves in the paused state. + let packet = this._paused(); + if (!packet) { + return { error: "notAttached" }; + } + packet.why = { type: "attached" }; + + // Send the response to the attach request now (rather than + // returning it), because we're going to start a nested event loop + // here. + this.conn.send(packet); + + // Start a nested event loop. + this._pushThreadPause(); + + // We already sent a response to this request, don't send one + // now. + return null; + } catch (e) { + reportError(e); + return { error: "notAttached", message: e.toString() }; + } + }, + + onDetach: function (aRequest) { + this.disconnect(); + this._state = "detached"; + this._debuggerSourcesSeen = null; + + dumpn("ThreadActor.prototype.onDetach: returning 'detached' packet"); + return { + type: "detached" + }; + }, + + onReconfigure: function (aRequest) { + if (this.state == "exited") { + return { error: "wrongState" }; + } + const options = aRequest.options || {}; + + if ("observeAsmJS" in options) { + this.dbg.allowUnobservedAsmJS = !options.observeAsmJS; + } + + Object.assign(this._options, options); + + // Update the global source store + this.sources.setOptions(options); + + return {}; + }, + + /** + * Pause the debuggee, by entering a nested event loop, and return a 'paused' + * packet to the client. + * + * @param Debugger.Frame aFrame + * The newest debuggee frame in the stack. + * @param object aReason + * An object with a 'type' property containing the reason for the pause. + * @param function onPacket + * Hook to modify the packet before it is sent. Feel free to return a + * promise. + */ + _pauseAndRespond: function (aFrame, aReason, onPacket = function (k) { return k; }) { + try { + let packet = this._paused(aFrame); + if (!packet) { + return undefined; + } + packet.why = aReason; + + let generatedLocation = this.sources.getFrameLocation(aFrame); + this.sources.getOriginalLocation(generatedLocation) + .then((originalLocation) => { + if (!originalLocation.originalSourceActor) { + // The only time the source actor will be null is if there + // was a sourcemap and it tried to look up the original + // location but there was no original URL. This is a strange + // scenario so we simply don't pause. + DevToolsUtils.reportException( + "ThreadActor", + new Error("Attempted to pause in a script with a sourcemap but " + + "could not find original location.") + ); + + return undefined; + } + + packet.frame.where = { + source: originalLocation.originalSourceActor.form(), + line: originalLocation.originalLine, + column: originalLocation.originalColumn + }; + resolve(onPacket(packet)) + .then(null, error => { + reportError(error); + return { + error: "unknownError", + message: error.message + "\n" + error.stack + }; + }) + .then(packet => { + this.conn.send(packet); + }); + }); + + this._pushThreadPause(); + } catch (e) { + reportError(e, "Got an exception during TA__pauseAndRespond: "); + } + + // If the browser tab has been closed, terminate the debuggee script + // instead of continuing. Executing JS after the content window is gone is + // a bad idea. + return this._tabClosed ? null : undefined; + }, + + _makeOnEnterFrame: function ({ pauseAndRespond }) { + return aFrame => { + const generatedLocation = this.sources.getFrameLocation(aFrame); + let { originalSourceActor } = this.unsafeSynchronize(this.sources.getOriginalLocation( + generatedLocation)); + let url = originalSourceActor.url; + + return this.sources.isBlackBoxed(url) + ? undefined + : pauseAndRespond(aFrame); + }; + }, + + _makeOnPop: function ({ thread, pauseAndRespond, createValueGrip }) { + return function (aCompletion) { + // onPop is called with 'this' set to the current frame. + + const generatedLocation = thread.sources.getFrameLocation(this); + const { originalSourceActor } = thread.unsafeSynchronize(thread.sources.getOriginalLocation( + generatedLocation)); + const url = originalSourceActor.url; + + if (thread.sources.isBlackBoxed(url)) { + return undefined; + } + + // Note that we're popping this frame; we need to watch for + // subsequent step events on its caller. + this.reportedPop = true; + + return pauseAndRespond(this, aPacket => { + aPacket.why.frameFinished = {}; + if (!aCompletion) { + aPacket.why.frameFinished.terminated = true; + } else if (aCompletion.hasOwnProperty("return")) { + aPacket.why.frameFinished.return = createValueGrip(aCompletion.return); + } else if (aCompletion.hasOwnProperty("yield")) { + aPacket.why.frameFinished.return = createValueGrip(aCompletion.yield); + } else { + aPacket.why.frameFinished.throw = createValueGrip(aCompletion.throw); + } + return aPacket; + }); + }; + }, + + _makeOnStep: function ({ thread, pauseAndRespond, startFrame, + startLocation, steppingType }) { + // Breaking in place: we should always pause. + if (steppingType === "break") { + return function () { + return pauseAndRespond(this); + }; + } + + // Otherwise take what a "step" means into consideration. + return function () { + // onStep is called with 'this' set to the current frame. + + // Only allow stepping stops at entry points for the line, when + // the stepping occurs in a single frame. The "same frame" + // check makes it so a sequence of steps can step out of a frame + // and into subsequent calls in the outer frame. E.g., if there + // is a call "a(b())" and the user steps into b, then this + // condition makes it possible to step out of b and into a. + if (this === startFrame && + !this.script.getOffsetLocation(this.offset).isEntryPoint) { + return undefined; + } + + const generatedLocation = thread.sources.getFrameLocation(this); + const newLocation = thread.unsafeSynchronize(thread.sources.getOriginalLocation( + generatedLocation)); + + // Cases when we should pause because we have executed enough to consider + // a "step" to have occured: + // + // 1.1. We change frames. + // 1.2. We change URLs (can happen without changing frames thanks to + // source mapping). + // 1.3. We change lines. + // + // Cases when we should always continue execution, even if one of the + // above cases is true: + // + // 2.1. We are in a source mapped region, but inside a null mapping + // (doesn't correlate to any region of original source) + // 2.2. The source we are in is black boxed. + + // Cases 2.1 and 2.2 + if (newLocation.originalUrl == null + || thread.sources.isBlackBoxed(newLocation.originalUrl)) { + return undefined; + } + + // Cases 1.1, 1.2 and 1.3 + if (this !== startFrame + || startLocation.originalUrl !== newLocation.originalUrl + || startLocation.originalLine !== newLocation.originalLine) { + return pauseAndRespond(this); + } + + // Otherwise, let execution continue (we haven't executed enough code to + // consider this a "step" yet). + return undefined; + }; + }, + + /** + * Define the JS hook functions for stepping. + */ + _makeSteppingHooks: function (aStartLocation, steppingType) { + // Bind these methods and state because some of the hooks are called + // with 'this' set to the current frame. Rather than repeating the + // binding in each _makeOnX method, just do it once here and pass it + // in to each function. + const steppingHookState = { + pauseAndRespond: (aFrame, onPacket = k=>k) => { + return this._pauseAndRespond(aFrame, { type: "resumeLimit" }, onPacket); + }, + createValueGrip: v => createValueGrip(v, this._pausePool, + this.objectGrip), + thread: this, + startFrame: this.youngestFrame, + startLocation: aStartLocation, + steppingType: steppingType + }; + + return { + onEnterFrame: this._makeOnEnterFrame(steppingHookState), + onPop: this._makeOnPop(steppingHookState), + onStep: this._makeOnStep(steppingHookState) + }; + }, + + /** + * Handle attaching the various stepping hooks we need to attach when we + * receive a resume request with a resumeLimit property. + * + * @param Object aRequest + * The request packet received over the RDP. + * @returns A promise that resolves to true once the hooks are attached, or is + * rejected with an error packet. + */ + _handleResumeLimit: function (aRequest) { + let steppingType = aRequest.resumeLimit.type; + if (["break", "step", "next", "finish"].indexOf(steppingType) == -1) { + return reject({ error: "badParameterType", + message: "Unknown resumeLimit type" }); + } + + const generatedLocation = this.sources.getFrameLocation(this.youngestFrame); + return this.sources.getOriginalLocation(generatedLocation) + .then(originalLocation => { + const { onEnterFrame, onPop, onStep } = this._makeSteppingHooks(originalLocation, + steppingType); + + // Make sure there is still a frame on the stack if we are to continue + // stepping. + let stepFrame = this._getNextStepFrame(this.youngestFrame); + if (stepFrame) { + switch (steppingType) { + case "step": + this.dbg.onEnterFrame = onEnterFrame; + // Fall through. + case "break": + case "next": + if (stepFrame.script) { + stepFrame.onStep = onStep; + } + stepFrame.onPop = onPop; + break; + case "finish": + stepFrame.onPop = onPop; + } + } + + return true; + }); + }, + + /** + * Clear the onStep and onPop hooks from the given frame and all of the frames + * below it. + * + * @param Debugger.Frame aFrame + * The frame we want to clear the stepping hooks from. + */ + _clearSteppingHooks: function (aFrame) { + if (aFrame && aFrame.live) { + while (aFrame) { + aFrame.onStep = undefined; + aFrame.onPop = undefined; + aFrame = aFrame.older; + } + } + }, + + /** + * Listen to the debuggee's DOM events if we received a request to do so. + * + * @param Object aRequest + * The resume request packet received over the RDP. + */ + _maybeListenToEvents: function (aRequest) { + // Break-on-DOMEvents is only supported in content debugging. + let events = aRequest.pauseOnDOMEvents; + if (this.global && events && + (events == "*" || + (Array.isArray(events) && events.length))) { + this._pauseOnDOMEvents = events; + let els = Cc["@mozilla.org/eventlistenerservice;1"] + .getService(Ci.nsIEventListenerService); + els.addListenerForAllEvents(this.global, this._allEventsListener, true); + } + }, + + /** + * If we are tasked with breaking on the load event, we have to add the + * listener early enough. + */ + _onWindowReady: function () { + this._maybeListenToEvents({ + pauseOnDOMEvents: this._pauseOnDOMEvents + }); + }, + + /** + * Handle a protocol request to resume execution of the debuggee. + */ + onResume: function (aRequest) { + if (this._state !== "paused") { + return { + error: "wrongState", + message: "Can't resume when debuggee isn't paused. Current state is '" + + this._state + "'", + state: this._state + }; + } + + // In case of multiple nested event loops (due to multiple debuggers open in + // different tabs or multiple debugger clients connected to the same tab) + // only allow resumption in a LIFO order. + if (this._nestedEventLoops.size && this._nestedEventLoops.lastPausedUrl + && (this._nestedEventLoops.lastPausedUrl !== this._parent.url + || this._nestedEventLoops.lastConnection !== this.conn)) { + return { + error: "wrongOrder", + message: "trying to resume in the wrong order.", + lastPausedUrl: this._nestedEventLoops.lastPausedUrl + }; + } + + let resumeLimitHandled; + if (aRequest && aRequest.resumeLimit) { + resumeLimitHandled = this._handleResumeLimit(aRequest); + } else { + this._clearSteppingHooks(this.youngestFrame); + resumeLimitHandled = resolve(true); + } + + return resumeLimitHandled.then(() => { + if (aRequest) { + this._options.pauseOnExceptions = aRequest.pauseOnExceptions; + this._options.ignoreCaughtExceptions = aRequest.ignoreCaughtExceptions; + this.maybePauseOnExceptions(); + this._maybeListenToEvents(aRequest); + } + + let packet = this._resumed(); + this._popThreadPause(); + // Tell anyone who cares of the resume (as of now, that's the xpcshell + // harness) + if (Services.obs) { + Services.obs.notifyObservers(this, "devtools-thread-resumed", null); + } + return packet; + }, error => { + return error instanceof Error + ? { error: "unknownError", + message: DevToolsUtils.safeErrorString(error) } + // It is a known error, and the promise was rejected with an error + // packet. + : error; + }); + }, + + /** + * Spin up a nested event loop so we can synchronously resolve a promise. + * + * DON'T USE THIS UNLESS YOU ABSOLUTELY MUST! Nested event loops suck: the + * world's state can change out from underneath your feet because JS is no + * longer run-to-completion. + * + * @param aPromise + * The promise we want to resolve. + * @returns The promise's resolution. + */ + unsafeSynchronize: function (aPromise) { + let needNest = true; + let eventLoop; + let returnVal; + + aPromise + .then((aResolvedVal) => { + needNest = false; + returnVal = aResolvedVal; + }) + .then(null, (aError) => { + reportError(aError, "Error inside unsafeSynchronize:"); + }) + .then(() => { + if (eventLoop) { + eventLoop.resolve(); + } + }); + + if (needNest) { + eventLoop = this._nestedEventLoops.push(); + eventLoop.enter(); + } + + return returnVal; + }, + + /** + * Set the debugging hook to pause on exceptions if configured to do so. + */ + maybePauseOnExceptions: function () { + if (this._options.pauseOnExceptions) { + this.dbg.onExceptionUnwind = this.onExceptionUnwind.bind(this); + } + }, + + /** + * A listener that gets called for every event fired on the page, when a list + * of interesting events was provided with the pauseOnDOMEvents property. It + * is used to set server-managed breakpoints on any existing event listeners + * for those events. + * + * @param Event event + * The event that was fired. + */ + _allEventsListener: function (event) { + if (this._pauseOnDOMEvents == "*" || + this._pauseOnDOMEvents.indexOf(event.type) != -1) { + for (let listener of this._getAllEventListeners(event.target)) { + if (event.type == listener.type || this._pauseOnDOMEvents == "*") { + this._breakOnEnter(listener.script); + } + } + } + }, + + /** + * Return an array containing all the event listeners attached to the + * specified event target and its ancestors in the event target chain. + * + * @param EventTarget eventTarget + * The target the event was dispatched on. + * @returns Array + */ + _getAllEventListeners: function (eventTarget) { + let els = Cc["@mozilla.org/eventlistenerservice;1"] + .getService(Ci.nsIEventListenerService); + + let targets = els.getEventTargetChainFor(eventTarget, true); + let listeners = []; + + for (let target of targets) { + let handlers = els.getListenerInfoFor(target); + for (let handler of handlers) { + // Null is returned for all-events handlers, and native event listeners + // don't provide any listenerObject, which makes them not that useful to + // a JS debugger. + if (!handler || !handler.listenerObject || !handler.type) + continue; + // Create a listener-like object suitable for our purposes. + let l = Object.create(null); + l.type = handler.type; + let listener = handler.listenerObject; + let listenerDO = this.globalDebugObject.makeDebuggeeValue(listener); + // If the listener is not callable, assume it is an event handler object. + if (!listenerDO.callable) { + // For some events we don't have permission to access the + // 'handleEvent' property when running in content scope. + if (!listenerDO.unwrap()) { + continue; + } + let heDesc; + while (!heDesc && listenerDO) { + heDesc = listenerDO.getOwnPropertyDescriptor("handleEvent"); + listenerDO = listenerDO.proto; + } + if (heDesc && heDesc.value) { + listenerDO = heDesc.value; + } + } + // When the listener is a bound function, we are actually interested in + // the target function. + while (listenerDO.isBoundFunction) { + listenerDO = listenerDO.boundTargetFunction; + } + l.script = listenerDO.script; + // Chrome listeners won't be converted to debuggee values, since their + // compartment is not added as a debuggee. + if (!l.script) + continue; + listeners.push(l); + } + } + return listeners; + }, + + /** + * Set a breakpoint on the first line of the given script that has an entry + * point. + */ + _breakOnEnter: function (script) { + let offsets = script.getAllOffsets(); + for (let line = 0, n = offsets.length; line < n; line++) { + if (offsets[line]) { + // N.B. Hidden breakpoints do not have an original location, and are not + // stored in the breakpoint actor map. + let actor = new BreakpointActor(this); + this.threadLifetimePool.addActor(actor); + + let scripts = this.dbg.findScripts({ source: script.source, line: line }); + let entryPoints = findEntryPointsForLine(scripts, line); + setBreakpointAtEntryPoints(actor, entryPoints); + this._hiddenBreakpoints.set(actor.actorID, actor); + break; + } + } + }, + + /** + * Helper method that returns the next frame when stepping. + */ + _getNextStepFrame: function (aFrame) { + let stepFrame = aFrame.reportedPop ? aFrame.older : aFrame; + if (!stepFrame || !stepFrame.script) { + stepFrame = null; + } + return stepFrame; + }, + + onClientEvaluate: function (aRequest) { + if (this.state !== "paused") { + return { error: "wrongState", + message: "Debuggee must be paused to evaluate code." }; + } + + let frame = this._requestFrame(aRequest.frame); + if (!frame) { + return { error: "unknownFrame", + message: "Evaluation frame not found" }; + } + + if (!frame.environment) { + return { error: "notDebuggee", + message: "cannot access the environment of this frame." }; + } + + let youngest = this.youngestFrame; + + // Put ourselves back in the running state and inform the client. + let resumedPacket = this._resumed(); + this.conn.send(resumedPacket); + + // Run the expression. + // XXX: test syntax errors + let completion = frame.eval(aRequest.expression); + + // Put ourselves back in the pause state. + let packet = this._paused(youngest); + packet.why = { type: "clientEvaluated", + frameFinished: this.createProtocolCompletionValue(completion) }; + + // Return back to our previous pause's event loop. + return packet; + }, + + onFrames: function (aRequest) { + if (this.state !== "paused") { + return { error: "wrongState", + message: "Stack frames are only available while the debuggee is paused."}; + } + + let start = aRequest.start ? aRequest.start : 0; + let count = aRequest.count; + + // Find the starting frame... + let frame = this.youngestFrame; + let i = 0; + while (frame && (i < start)) { + frame = frame.older; + i++; + } + + // Return request.count frames, or all remaining + // frames if count is not defined. + let promises = []; + for (; frame && (!count || i < (start + count)); i++, frame = frame.older) { + let form = this._createFrameActor(frame).form(); + form.depth = i; + + let promise = this.sources.getOriginalLocation(new GeneratedLocation( + this.sources.createNonSourceMappedActor(frame.script.source), + form.where.line, + form.where.column + )).then((originalLocation) => { + if (!originalLocation.originalSourceActor) { + return null; + } + + let sourceForm = originalLocation.originalSourceActor.form(); + form.where = { + source: sourceForm, + line: originalLocation.originalLine, + column: originalLocation.originalColumn + }; + form.source = sourceForm; + return form; + }); + promises.push(promise); + } + + return all(promises).then(function (frames) { + // Filter null values because sourcemapping may have failed. + return { frames: frames.filter(x => !!x) }; + }); + }, + + onReleaseMany: function (aRequest) { + if (!aRequest.actors) { + return { error: "missingParameter", + message: "no actors were specified" }; + } + + let res; + for (let actorID of aRequest.actors) { + let actor = this.threadLifetimePool.get(actorID); + if (!actor) { + if (!res) { + res = { error: "notReleasable", + message: "Only thread-lifetime actors can be released." }; + } + continue; + } + actor.onRelease(); + } + return res ? res : {}; + }, + + /** + * Get the script and source lists from the debugger. + */ + _discoverSources: function () { + // Only get one script per Debugger.Source. + const sourcesToScripts = new Map(); + const scripts = this.dbg.findScripts(); + + for (let i = 0, len = scripts.length; i < len; i++) { + let s = scripts[i]; + if (s.source) { + sourcesToScripts.set(s.source, s); + } + } + + return all([...sourcesToScripts.values()].map(script => { + return this.sources.createSourceActors(script.source); + })); + }, + + onSources: function (aRequest) { + return this._discoverSources().then(() => { + // No need to flush the new source packets here, as we are sending the + // list of sources out immediately and we don't need to invoke the + // overhead of an RDP packet for every source right now. Let the default + // timeout flush the buffered packets. + + return { + sources: this.sources.iter().map(s => s.form()) + }; + }); + }, + + /** + * Disassociate all breakpoint actors from their scripts and clear the + * breakpoint handlers. This method can be used when the thread actor intends + * to keep the breakpoint store, but needs to clear any actual breakpoints, + * e.g. due to a page navigation. This way the breakpoint actors' script + * caches won't hold on to the Debugger.Script objects leaking memory. + */ + disableAllBreakpoints: function () { + for (let bpActor of this.breakpointActorMap.findActors()) { + bpActor.removeScripts(); + } + }, + + + /** + * Handle a protocol request to pause the debuggee. + */ + onInterrupt: function (aRequest) { + if (this.state == "exited") { + return { type: "exited" }; + } else if (this.state == "paused") { + // TODO: return the actual reason for the existing pause. + return { type: "paused", why: { type: "alreadyPaused" } }; + } else if (this.state != "running") { + return { error: "wrongState", + message: "Received interrupt request in " + this.state + + " state." }; + } + + try { + // If execution should pause just before the next JavaScript bytecode is + // executed, just set an onEnterFrame handler. + if (aRequest.when == "onNext") { + let onEnterFrame = (aFrame) => { + return this._pauseAndRespond(aFrame, { type: "interrupted", onNext: true }); + }; + this.dbg.onEnterFrame = onEnterFrame; + + return { type: "willInterrupt" }; + } + + // If execution should pause immediately, just put ourselves in the paused + // state. + let packet = this._paused(); + if (!packet) { + return { error: "notInterrupted" }; + } + packet.why = { type: "interrupted" }; + + // Send the response to the interrupt request now (rather than + // returning it), because we're going to start a nested event loop + // here. + this.conn.send(packet); + + // Start a nested event loop. + this._pushThreadPause(); + + // We already sent a response to this request, don't send one + // now. + return null; + } catch (e) { + reportError(e); + return { error: "notInterrupted", message: e.toString() }; + } + }, + + /** + * Handle a protocol request to retrieve all the event listeners on the page. + */ + onEventListeners: function (aRequest) { + // This request is only supported in content debugging. + if (!this.global) { + return { + error: "notImplemented", + message: "eventListeners request is only supported in content debugging" + }; + } + + let els = Cc["@mozilla.org/eventlistenerservice;1"] + .getService(Ci.nsIEventListenerService); + + let nodes = this.global.document.getElementsByTagName("*"); + nodes = [this.global].concat([].slice.call(nodes)); + let listeners = []; + + for (let node of nodes) { + let handlers = els.getListenerInfoFor(node); + + for (let handler of handlers) { + // Create a form object for serializing the listener via the protocol. + let listenerForm = Object.create(null); + let listener = handler.listenerObject; + // Native event listeners don't provide any listenerObject or type and + // are not that useful to a JS debugger. + if (!listener || !handler.type) { + continue; + } + + // There will be no tagName if the event listener is set on the window. + let selector = node.tagName ? CssLogic.findCssSelector(node) : "window"; + let nodeDO = this.globalDebugObject.makeDebuggeeValue(node); + listenerForm.node = { + selector: selector, + object: createValueGrip(nodeDO, this._pausePool, this.objectGrip) + }; + listenerForm.type = handler.type; + listenerForm.capturing = handler.capturing; + listenerForm.allowsUntrusted = handler.allowsUntrusted; + listenerForm.inSystemEventGroup = handler.inSystemEventGroup; + let handlerName = "on" + listenerForm.type; + listenerForm.isEventHandler = false; + if (typeof node.hasAttribute !== "undefined") { + listenerForm.isEventHandler = !!node.hasAttribute(handlerName); + } + if (!!node[handlerName]) { + listenerForm.isEventHandler = !!node[handlerName]; + } + // Get the Debugger.Object for the listener object. + let listenerDO = this.globalDebugObject.makeDebuggeeValue(listener); + // If the listener is an object with a 'handleEvent' method, use that. + if (listenerDO.class == "Object" || listenerDO.class == "XULElement") { + // For some events we don't have permission to access the + // 'handleEvent' property when running in content scope. + if (!listenerDO.unwrap()) { + continue; + } + let heDesc; + while (!heDesc && listenerDO) { + heDesc = listenerDO.getOwnPropertyDescriptor("handleEvent"); + listenerDO = listenerDO.proto; + } + if (heDesc && heDesc.value) { + listenerDO = heDesc.value; + } + } + // When the listener is a bound function, we are actually interested in + // the target function. + while (listenerDO.isBoundFunction) { + listenerDO = listenerDO.boundTargetFunction; + } + listenerForm.function = createValueGrip(listenerDO, this._pausePool, + this.objectGrip); + listeners.push(listenerForm); + } + } + return { listeners: listeners }; + }, + + /** + * Return the Debug.Frame for a frame mentioned by the protocol. + */ + _requestFrame: function (aFrameID) { + if (!aFrameID) { + return this.youngestFrame; + } + + if (this._framePool.has(aFrameID)) { + return this._framePool.get(aFrameID).frame; + } + + return undefined; + }, + + _paused: function (aFrame) { + // We don't handle nested pauses correctly. Don't try - if we're + // paused, just continue running whatever code triggered the pause. + // We don't want to actually have nested pauses (although we + // have nested event loops). If code runs in the debuggee during + // a pause, it should cause the actor to resume (dropping + // pause-lifetime actors etc) and then repause when complete. + + if (this.state === "paused") { + return undefined; + } + + // Clear stepping hooks. + this.dbg.onEnterFrame = undefined; + this.dbg.onExceptionUnwind = undefined; + if (aFrame) { + aFrame.onStep = undefined; + aFrame.onPop = undefined; + } + + // Clear DOM event breakpoints. + // XPCShell tests don't use actual DOM windows for globals and cause + // removeListenerForAllEvents to throw. + if (!isWorker && this.global && !this.global.toString().includes("Sandbox")) { + let els = Cc["@mozilla.org/eventlistenerservice;1"] + .getService(Ci.nsIEventListenerService); + els.removeListenerForAllEvents(this.global, this._allEventsListener, true); + for (let [, bp] of this._hiddenBreakpoints) { + bp.delete(); + } + this._hiddenBreakpoints.clear(); + } + + this._state = "paused"; + + // Create the actor pool that will hold the pause actor and its + // children. + assert(!this._pausePool, "No pause pool should exist yet"); + this._pausePool = new ActorPool(this.conn); + this.conn.addActorPool(this._pausePool); + + // Give children of the pause pool a quick link back to the + // thread... + this._pausePool.threadActor = this; + + // Create the pause actor itself... + assert(!this._pauseActor, "No pause actor should exist yet"); + this._pauseActor = new PauseActor(this._pausePool); + this._pausePool.addActor(this._pauseActor); + + // Update the list of frames. + let poppedFrames = this._updateFrames(); + + // Send off the paused packet and spin an event loop. + let packet = { from: this.actorID, + type: "paused", + actor: this._pauseActor.actorID }; + if (aFrame) { + packet.frame = this._createFrameActor(aFrame).form(); + } + + if (poppedFrames) { + packet.poppedFrames = poppedFrames; + } + + return packet; + }, + + _resumed: function () { + this._state = "running"; + + // Drop the actors in the pause actor pool. + this.conn.removeActorPool(this._pausePool); + + this._pausePool = null; + this._pauseActor = null; + + return { from: this.actorID, type: "resumed" }; + }, + + /** + * Expire frame actors for frames that have been popped. + * + * @returns A list of actor IDs whose frames have been popped. + */ + _updateFrames: function () { + let popped = []; + + // Create the actor pool that will hold the still-living frames. + let framePool = new ActorPool(this.conn); + let frameList = []; + + for (let frameActor of this._frameActors) { + if (frameActor.frame.live) { + framePool.addActor(frameActor); + frameList.push(frameActor); + } else { + popped.push(frameActor.actorID); + } + } + + // Remove the old frame actor pool, this will expire + // any actors that weren't added to the new pool. + if (this._framePool) { + this.conn.removeActorPool(this._framePool); + } + + this._frameActors = frameList; + this._framePool = framePool; + this.conn.addActorPool(framePool); + + return popped; + }, + + _createFrameActor: function (aFrame) { + if (aFrame.actor) { + return aFrame.actor; + } + + let actor = new FrameActor(aFrame, this); + this._frameActors.push(actor); + this._framePool.addActor(actor); + aFrame.actor = actor; + + return actor; + }, + + /** + * Create and return an environment actor that corresponds to the provided + * Debugger.Environment. + * @param Debugger.Environment aEnvironment + * The lexical environment we want to extract. + * @param object aPool + * The pool where the newly-created actor will be placed. + * @return The EnvironmentActor for aEnvironment or undefined for host + * functions or functions scoped to a non-debuggee global. + */ + createEnvironmentActor: function (aEnvironment, aPool) { + if (!aEnvironment) { + return undefined; + } + + if (aEnvironment.actor) { + return aEnvironment.actor; + } + + let actor = new EnvironmentActor(aEnvironment, this); + aPool.addActor(actor); + aEnvironment.actor = actor; + + return actor; + }, + + /** + * Return a protocol completion value representing the given + * Debugger-provided completion value. + */ + createProtocolCompletionValue: function (aCompletion) { + let protoValue = {}; + if (aCompletion == null) { + protoValue.terminated = true; + } else if ("return" in aCompletion) { + protoValue.return = createValueGrip(aCompletion.return, + this._pausePool, this.objectGrip); + } else if ("throw" in aCompletion) { + protoValue.throw = createValueGrip(aCompletion.throw, + this._pausePool, this.objectGrip); + } else { + protoValue.return = createValueGrip(aCompletion.yield, + this._pausePool, this.objectGrip); + } + return protoValue; + }, + + /** + * Create a grip for the given debuggee object. + * + * @param aValue Debugger.Object + * The debuggee object value. + * @param aPool ActorPool + * The actor pool where the new object actor will be added. + */ + objectGrip: function (aValue, aPool) { + if (!aPool.objectActors) { + aPool.objectActors = new WeakMap(); + } + + if (aPool.objectActors.has(aValue)) { + return aPool.objectActors.get(aValue).grip(); + } else if (this.threadLifetimePool.objectActors.has(aValue)) { + return this.threadLifetimePool.objectActors.get(aValue).grip(); + } + + let actor = new PauseScopedObjectActor(aValue, { + getGripDepth: () => this._gripDepth, + incrementGripDepth: () => this._gripDepth++, + decrementGripDepth: () => this._gripDepth--, + createValueGrip: v => createValueGrip(v, this._pausePool, + this.pauseObjectGrip), + sources: () => this.sources, + createEnvironmentActor: (env, pool) => + this.createEnvironmentActor(env, pool), + promote: () => this.threadObjectGrip(actor), + isThreadLifetimePool: () => + actor.registeredPool !== this.threadLifetimePool, + getGlobalDebugObject: () => this.globalDebugObject + }); + aPool.addActor(actor); + aPool.objectActors.set(aValue, actor); + return actor.grip(); + }, + + /** + * Create a grip for the given debuggee object with a pause lifetime. + * + * @param aValue Debugger.Object + * The debuggee object value. + */ + pauseObjectGrip: function (aValue) { + if (!this._pausePool) { + throw "Object grip requested while not paused."; + } + + return this.objectGrip(aValue, this._pausePool); + }, + + /** + * Extend the lifetime of the provided object actor to thread lifetime. + * + * @param aActor object + * The object actor. + */ + threadObjectGrip: function (aActor) { + // We want to reuse the existing actor ID, so we just remove it from the + // current pool's weak map and then let pool.addActor do the rest. + aActor.registeredPool.objectActors.delete(aActor.obj); + this.threadLifetimePool.addActor(aActor); + this.threadLifetimePool.objectActors.set(aActor.obj, aActor); + }, + + /** + * Handle a protocol request to promote multiple pause-lifetime grips to + * thread-lifetime grips. + * + * @param aRequest object + * The protocol request object. + */ + onThreadGrips: function (aRequest) { + if (this.state != "paused") { + return { error: "wrongState" }; + } + + if (!aRequest.actors) { + return { error: "missingParameter", + message: "no actors were specified" }; + } + + for (let actorID of aRequest.actors) { + let actor = this._pausePool.get(actorID); + if (actor) { + this.threadObjectGrip(actor); + } + } + return {}; + }, + + /** + * Create a long string grip that is scoped to a pause. + * + * @param aString String + * The string we are creating a grip for. + */ + pauseLongStringGrip: function (aString) { + return longStringGrip(aString, this._pausePool); + }, + + /** + * Create a long string grip that is scoped to a thread. + * + * @param aString String + * The string we are creating a grip for. + */ + threadLongStringGrip: function (aString) { + return longStringGrip(aString, this._threadLifetimePool); + }, + + // JS Debugger API hooks. + + /** + * A function that the engine calls when a call to a debug event hook, + * breakpoint handler, watchpoint handler, or similar function throws some + * exception. + * + * @param aException exception + * The exception that was thrown in the debugger code. + */ + uncaughtExceptionHook: function (aException) { + dumpn("Got an exception: " + aException.message + "\n" + aException.stack); + }, + + /** + * A function that the engine calls when a debugger statement has been + * executed in the specified frame. + * + * @param aFrame Debugger.Frame + * The stack frame that contained the debugger statement. + */ + onDebuggerStatement: function (aFrame) { + // Don't pause if we are currently stepping (in or over) or the frame is + // black-boxed. + const generatedLocation = this.sources.getFrameLocation(aFrame); + const { originalSourceActor } = this.unsafeSynchronize(this.sources.getOriginalLocation( + generatedLocation)); + const url = originalSourceActor ? originalSourceActor.url : null; + + return this.sources.isBlackBoxed(url) || aFrame.onStep + ? undefined + : this._pauseAndRespond(aFrame, { type: "debuggerStatement" }); + }, + + /** + * A function that the engine calls when an exception has been thrown and has + * propagated to the specified frame. + * + * @param aFrame Debugger.Frame + * The youngest remaining stack frame. + * @param aValue object + * The exception that was thrown. + */ + onExceptionUnwind: function (aFrame, aValue) { + let willBeCaught = false; + for (let frame = aFrame; frame != null; frame = frame.older) { + if (frame.script.isInCatchScope(frame.offset)) { + willBeCaught = true; + break; + } + } + + if (willBeCaught && this._options.ignoreCaughtExceptions) { + return undefined; + } + + // NS_ERROR_NO_INTERFACE exceptions are a special case in browser code, + // since they're almost always thrown by QueryInterface functions, and + // handled cleanly by native code. + if (aValue == Cr.NS_ERROR_NO_INTERFACE) { + return undefined; + } + + const generatedLocation = this.sources.getFrameLocation(aFrame); + const { originalSourceActor } = this.unsafeSynchronize(this.sources.getOriginalLocation( + generatedLocation)); + const url = originalSourceActor ? originalSourceActor.url : null; + + if (this.sources.isBlackBoxed(url)) { + return undefined; + } + + try { + let packet = this._paused(aFrame); + if (!packet) { + return undefined; + } + + packet.why = { type: "exception", + exception: createValueGrip(aValue, this._pausePool, + this.objectGrip) + }; + this.conn.send(packet); + + this._pushThreadPause(); + } catch (e) { + reportError(e, "Got an exception during TA_onExceptionUnwind: "); + } + + return undefined; + }, + + /** + * A function that the engine calls when a new script has been loaded into the + * scope of the specified debuggee global. + * + * @param aScript Debugger.Script + * The source script that has been loaded into a debuggee compartment. + * @param aGlobal Debugger.Object + * A Debugger.Object instance whose referent is the global object. + */ + onNewScript: function (aScript, aGlobal) { + this._addSource(aScript.source); + }, + + /** + * A function called when there's a new or updated source from a thread actor's + * sources. Emits `newSource` and `updatedSource` on the tab actor. + * + * @param {String} name + * @param {SourceActor} source + */ + onSourceEvent: function (name, source) { + this.conn.send({ + from: this._parent.actorID, + type: name, + source: source.form() + }); + + // For compatibility and debugger still using `newSource` on the thread client, + // still emit this event here. Clean up in bug 1247084 + if (name === "newSource") { + this.conn.send({ + from: this.actorID, + type: name, + source: source.form() + }); + } + }, + + /** + * Add the provided source to the server cache. + * + * @param aSource Debugger.Source + * The source that will be stored. + * @returns true, if the source was added; false otherwise. + */ + _addSource: function (aSource) { + if (!this.sources.allowSource(aSource) || this._debuggerSourcesSeen.has(aSource)) { + return false; + } + + let sourceActor = this.sources.createNonSourceMappedActor(aSource); + let bpActors = [...this.breakpointActorMap.findActors()]; + + if (this._options.useSourceMaps) { + let promises = []; + + // Go ahead and establish the source actors for this script, which + // fetches sourcemaps if available and sends onNewSource + // notifications. + let sourceActorsCreated = this.sources._createSourceMappedActors(aSource); + + if (bpActors.length) { + // We need to use unsafeSynchronize here because if the page is being reloaded, + // this call will replace the previous set of source actors for this source + // with a new one. If the source actors have not been replaced by the time + // we try to reset the breakpoints below, their location objects will still + // point to the old set of source actors, which point to different + // scripts. + this.unsafeSynchronize(sourceActorsCreated); + } + + for (let _actor of bpActors) { + // XXX bug 1142115: We do async work in here, so we need to create a fresh + // binding because for/of does not yet do that in SpiderMonkey. + let actor = _actor; + + if (actor.isPending) { + promises.push(actor.originalLocation.originalSourceActor._setBreakpoint(actor)); + } else { + promises.push(this.sources.getAllGeneratedLocations(actor.originalLocation) + .then((generatedLocations) => { + if (generatedLocations.length > 0 && + generatedLocations[0].generatedSourceActor.actorID === sourceActor.actorID) { + sourceActor._setBreakpointAtAllGeneratedLocations(actor, generatedLocations); + } + })); + } + } + + if (promises.length > 0) { + this.unsafeSynchronize(promise.all(promises)); + } + } else { + // Bug 1225160: If addSource is called in response to a new script + // notification, and this notification was triggered by loading a JSM from + // chrome code, calling unsafeSynchronize could cause a debuggee timer to + // fire. If this causes the JSM to be loaded a second time, the browser + // will crash, because loading JSMS is not reentrant, and the first load + // has not completed yet. + // + // The root of the problem is that unsafeSynchronize can cause debuggee + // code to run. Unfortunately, fixing that is prohibitively difficult. The + // best we can do at the moment is disable source maps for the browser + // debugger, and carefully avoid the use of unsafeSynchronize in this + // function when source maps are disabled. + for (let actor of bpActors) { + if (actor.isPending) { + actor.originalLocation.originalSourceActor._setBreakpoint(actor); + } else { + actor.originalLocation.originalSourceActor._setBreakpointAtGeneratedLocation( + actor, GeneratedLocation.fromOriginalLocation(actor.originalLocation) + ); + } + } + } + + this._debuggerSourcesSeen.add(aSource); + return true; + }, + + + /** + * Get prototypes and properties of multiple objects. + */ + onPrototypesAndProperties: function (aRequest) { + let result = {}; + for (let actorID of aRequest.actors) { + // This code assumes that there are no lazily loaded actors returned + // by this call. + let actor = this.conn.getActor(actorID); + if (!actor) { + return { from: this.actorID, + error: "noSuchActor" }; + } + let handler = actor.onPrototypeAndProperties; + if (!handler) { + return { from: this.actorID, + error: "unrecognizedPacketType", + message: ('Actor "' + actorID + + '" does not recognize the packet type ' + + '"prototypeAndProperties"') }; + } + result[actorID] = handler.call(actor, {}); + } + return { from: this.actorID, + actors: result }; + } +}); + +ThreadActor.prototype.requestTypes = object.merge(ThreadActor.prototype.requestTypes, { + "attach": ThreadActor.prototype.onAttach, + "detach": ThreadActor.prototype.onDetach, + "reconfigure": ThreadActor.prototype.onReconfigure, + "resume": ThreadActor.prototype.onResume, + "clientEvaluate": ThreadActor.prototype.onClientEvaluate, + "frames": ThreadActor.prototype.onFrames, + "interrupt": ThreadActor.prototype.onInterrupt, + "eventListeners": ThreadActor.prototype.onEventListeners, + "releaseMany": ThreadActor.prototype.onReleaseMany, + "sources": ThreadActor.prototype.onSources, + "threadGrips": ThreadActor.prototype.onThreadGrips, + "prototypesAndProperties": ThreadActor.prototype.onPrototypesAndProperties +}); + +exports.ThreadActor = ThreadActor; + +/** + * Creates a PauseActor. + * + * PauseActors exist for the lifetime of a given debuggee pause. Used to + * scope pause-lifetime grips. + * + * @param ActorPool aPool + * The actor pool created for this pause. + */ +function PauseActor(aPool) +{ + this.pool = aPool; +} + +PauseActor.prototype = { + actorPrefix: "pause" +}; + + +/** + * A base actor for any actors that should only respond receive messages in the + * paused state. Subclasses may expose a `threadActor` which is used to help + * determine when we are in a paused state. Subclasses should set their own + * "constructor" property if they want better error messages. You should never + * instantiate a PauseScopedActor directly, only through subclasses. + */ +function PauseScopedActor() +{ +} + +/** + * A function decorator for creating methods to handle protocol messages that + * should only be received while in the paused state. + * + * @param aMethod Function + * The function we are decorating. + */ +PauseScopedActor.withPaused = function (aMethod) { + return function () { + if (this.isPaused()) { + return aMethod.apply(this, arguments); + } else { + return this._wrongState(); + } + }; +}; + +PauseScopedActor.prototype = { + + /** + * Returns true if we are in the paused state. + */ + isPaused: function () { + // When there is not a ThreadActor available (like in the webconsole) we + // have to be optimistic and assume that we are paused so that we can + // respond to requests. + return this.threadActor ? this.threadActor.state === "paused" : true; + }, + + /** + * Returns the wrongState response packet for this actor. + */ + _wrongState: function () { + return { + error: "wrongState", + message: this.constructor.name + + " actors can only be accessed while the thread is paused." + }; + } +}; + +/** + * Creates a pause-scoped actor for the specified object. + * @see ObjectActor + */ +function PauseScopedObjectActor(obj, hooks) { + ObjectActor.call(this, obj, hooks); + this.hooks.promote = hooks.promote; + this.hooks.isThreadLifetimePool = hooks.isThreadLifetimePool; +} + +PauseScopedObjectActor.prototype = Object.create(PauseScopedActor.prototype); + +Object.assign(PauseScopedObjectActor.prototype, ObjectActor.prototype); + +Object.assign(PauseScopedObjectActor.prototype, { + constructor: PauseScopedObjectActor, + actorPrefix: "pausedobj", + + onOwnPropertyNames: + PauseScopedActor.withPaused(ObjectActor.prototype.onOwnPropertyNames), + + onPrototypeAndProperties: + PauseScopedActor.withPaused(ObjectActor.prototype.onPrototypeAndProperties), + + onPrototype: PauseScopedActor.withPaused(ObjectActor.prototype.onPrototype), + onProperty: PauseScopedActor.withPaused(ObjectActor.prototype.onProperty), + onDecompile: PauseScopedActor.withPaused(ObjectActor.prototype.onDecompile), + + onDisplayString: + PauseScopedActor.withPaused(ObjectActor.prototype.onDisplayString), + + onParameterNames: + PauseScopedActor.withPaused(ObjectActor.prototype.onParameterNames), + + /** + * Handle a protocol request to promote a pause-lifetime grip to a + * thread-lifetime grip. + * + * @param aRequest object + * The protocol request object. + */ + onThreadGrip: PauseScopedActor.withPaused(function (aRequest) { + this.hooks.promote(); + return {}; + }), + + /** + * Handle a protocol request to release a thread-lifetime grip. + * + * @param aRequest object + * The protocol request object. + */ + onRelease: PauseScopedActor.withPaused(function (aRequest) { + if (this.hooks.isThreadLifetimePool()) { + return { error: "notReleasable", + message: "Only thread-lifetime actors can be released." }; + } + + this.release(); + return {}; + }), +}); + +Object.assign(PauseScopedObjectActor.prototype.requestTypes, { + "threadGrip": PauseScopedObjectActor.prototype.onThreadGrip, +}); + +function hackDebugger(Debugger) { + // TODO: Improve native code instead of hacking on top of it + + /** + * Override the toString method in order to get more meaningful script output + * for debugging the debugger. + */ + Debugger.Script.prototype.toString = function () { + let output = ""; + if (this.url) { + output += this.url; + } + if (typeof this.staticLevel != "undefined") { + output += ":L" + this.staticLevel; + } + if (typeof this.startLine != "undefined") { + output += ":" + this.startLine; + if (this.lineCount && this.lineCount > 1) { + output += "-" + (this.startLine + this.lineCount - 1); + } + } + if (typeof this.startLine != "undefined") { + output += ":" + this.startLine; + if (this.lineCount && this.lineCount > 1) { + output += "-" + (this.startLine + this.lineCount - 1); + } + } + if (this.strictMode) { + output += ":strict"; + } + return output; + }; + + /** + * Helper property for quickly getting to the line number a stack frame is + * currently paused at. + */ + Object.defineProperty(Debugger.Frame.prototype, "line", { + configurable: true, + get: function () { + if (this.script) { + return this.script.getOffsetLocation(this.offset).lineNumber; + } else { + return null; + } + } + }); +} + + +/** + * Creates an actor for handling chrome debugging. ChromeDebuggerActor is a + * thin wrapper over ThreadActor, slightly changing some of its behavior. + * + * @param aConnection object + * The DebuggerServerConnection with which this ChromeDebuggerActor + * is associated. (Currently unused, but required to make this + * constructor usable with addGlobalActor.) + * + * @param aParent object + * This actor's parent actor. See ThreadActor for a list of expected + * properties. + */ +function ChromeDebuggerActor(aConnection, aParent) +{ + ThreadActor.prototype.initialize.call(this, aParent); +} + +ChromeDebuggerActor.prototype = Object.create(ThreadActor.prototype); + +Object.assign(ChromeDebuggerActor.prototype, { + constructor: ChromeDebuggerActor, + + // A constant prefix that will be used to form the actor ID by the server. + actorPrefix: "chromeDebugger" +}); + +exports.ChromeDebuggerActor = ChromeDebuggerActor; + +/** + * Creates an actor for handling add-on debugging. AddonThreadActor is + * a thin wrapper over ThreadActor. + * + * @param aConnection object + * The DebuggerServerConnection with which this AddonThreadActor + * is associated. (Currently unused, but required to make this + * constructor usable with addGlobalActor.) + * + * @param aParent object + * This actor's parent actor. See ThreadActor for a list of expected + * properties. + */ +function AddonThreadActor(aConnect, aParent) { + ThreadActor.prototype.initialize.call(this, aParent); +} + +AddonThreadActor.prototype = Object.create(ThreadActor.prototype); + +Object.assign(AddonThreadActor.prototype, { + constructor: AddonThreadActor, + + // A constant prefix that will be used to form the actor ID by the server. + actorPrefix: "addonThread" +}); + +exports.AddonThreadActor = AddonThreadActor; + +// Utility functions. + +/** + * Report the given error in the error console and to stdout. + * + * @param Error aError + * The error object you wish to report. + * @param String aPrefix + * An optional prefix for the reported error message. + */ +var oldReportError = reportError; +reportError = function (aError, aPrefix = "") { + assert(aError instanceof Error, "Must pass Error objects to reportError"); + let msg = aPrefix + aError.message + ":\n" + aError.stack; + oldReportError(msg); + dumpn(msg); +}; + +/** + * Find the scripts which contain offsets that are an entry point to the given + * line. + * + * @param Array scripts + * The set of Debugger.Scripts to consider. + * @param Number line + * The line we are searching for entry points into. + * @returns Array of objects of the form { script, offsets } where: + * - script is a Debugger.Script + * - offsets is an array of offsets that are entry points into the + * given line. + */ +function findEntryPointsForLine(scripts, line) { + const entryPoints = []; + for (let script of scripts) { + const offsets = script.getLineOffsets(line); + if (offsets.length) { + entryPoints.push({ script, offsets }); + } + } + return entryPoints; +} + +/** + * Unwrap a global that is wrapped in a |Debugger.Object|, or if the global has + * become a dead object, return |undefined|. + * + * @param Debugger.Object wrappedGlobal + * The |Debugger.Object| which wraps a global. + * + * @returns {Object|undefined} + * Returns the unwrapped global object or |undefined| if unwrapping + * failed. + */ +exports.unwrapDebuggerObjectGlobal = wrappedGlobal => { + try { + // Because of bug 991399 we sometimes get nuked window references here. We + // just bail out in that case. + // + // Note that addon sandboxes have a DOMWindow as their prototype. So make + // sure that we can touch the prototype too (whatever it is), in case _it_ + // is it a nuked window reference. We force stringification to make sure + // that any dead object proxies make themselves known. + let global = wrappedGlobal.unsafeDereference(); + Object.getPrototypeOf(global) + ""; + return global; + } + catch (e) { + return undefined; + } +}; diff --git a/devtools/server/actors/settings.js b/devtools/server/actors/settings.js new file mode 100644 index 000000000..179c82aa5 --- /dev/null +++ b/devtools/server/actors/settings.js @@ -0,0 +1,146 @@ +/* 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 {Cc, Ci} = require("chrome"); +const protocol = require("devtools/shared/protocol"); +const {DebuggerServer} = require("devtools/server/main"); +const promise = require("promise"); +const Services = require("Services"); +const { settingsSpec } = require("devtools/shared/specs/settings"); +const { FileUtils} = require("resource://gre/modules/FileUtils.jsm"); +const { NetUtil} = require("resource://gre/modules/NetUtil.jsm"); + +var defaultSettings = {}; +var settingsFile; + +exports.register = function (handle) { + handle.addGlobalActor(SettingsActor, "settingsActor"); +}; + +exports.unregister = function (handle) { +}; + +function getDefaultSettings() { + let chan = NetUtil.newChannel({ + uri: NetUtil.newURI(settingsFile), + loadUsingSystemPrincipal: true}); + let stream = chan.open2(); + // Obtain a converter to read from a UTF-8 encoded input stream. + let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + let rawstr = converter.ConvertToUnicode(NetUtil.readInputStreamToString( + stream, + stream.available()) || ""); + + try { + defaultSettings = JSON.parse(rawstr); + } catch (e) { } + stream.close(); +} + +function loadSettingsFile() { + // Loading resource://app/defaults/settings.json doesn't work because + // settings.json is not in the omnijar. + // So we look for the app dir instead and go from here... + if (settingsFile) { + return; + } + settingsFile = FileUtils.getFile("DefRt", ["settings.json"], false); + if (!settingsFile || (settingsFile && !settingsFile.exists())) { + // On b2g desktop builds the settings.json file is moved in the + // profile directory by the build system. + settingsFile = FileUtils.getFile("ProfD", ["settings.json"], false); + if (!settingsFile || (settingsFile && !settingsFile.exists())) { + console.log("settings.json file does not exist"); + } + } + + if (settingsFile.exists()) { + getDefaultSettings(); + } +} + +var SettingsActor = exports.SettingsActor = protocol.ActorClassWithSpec(settingsSpec, { + _getSettingsService: function () { + let win = Services.wm.getMostRecentWindow(DebuggerServer.chromeWindowType); + return win.navigator.mozSettings; + }, + + getSetting: function (name) { + let deferred = promise.defer(); + let lock = this._getSettingsService().createLock(); + let req = lock.get(name); + req.onsuccess = function () { + deferred.resolve(req.result[name]); + }; + req.onerror = function () { + deferred.reject(req.error); + }; + return deferred.promise; + }, + + setSetting: function (name, value) { + let deferred = promise.defer(); + let data = {}; + data[name] = value; + let lock = this._getSettingsService().createLock(); + let req = lock.set(data); + req.onsuccess = function () { + deferred.resolve(true); + }; + req.onerror = function () { + deferred.reject(req.error); + }; + return deferred.promise; + }, + + _hasUserSetting: function (name, value) { + if (typeof value === "object") { + return JSON.stringify(defaultSettings[name]) !== JSON.stringify(value); + } + return (defaultSettings[name] !== value); + }, + + getAllSettings: function () { + loadSettingsFile(); + let settings = {}; + let self = this; + + let deferred = promise.defer(); + let lock = this._getSettingsService().createLock(); + let req = lock.get("*"); + + req.onsuccess = function () { + for (var name in req.result) { + settings[name] = { + value: req.result[name], + hasUserValue: self._hasUserSetting(name, req.result[name]) + }; + } + deferred.resolve(settings); + }; + req.onfailure = function () { + deferred.reject(req.error); + }; + + return deferred.promise; + }, + + clearUserSetting: function (name) { + loadSettingsFile(); + try { + this.setSetting(name, defaultSettings[name]); + } catch (e) { + console.log(e); + } + } +}); + +// For tests +exports._setDefaultSettings = function (settings) { + defaultSettings = settings || {}; +}; diff --git a/devtools/server/actors/source.js b/devtools/server/actors/source.js new file mode 100644 index 000000000..e76c14fe8 --- /dev/null +++ b/devtools/server/actors/source.js @@ -0,0 +1,902 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; 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 { Cc, Ci } = require("chrome"); +const Services = require("Services"); +const { BreakpointActor, setBreakpointAtEntryPoints } = require("devtools/server/actors/breakpoint"); +const { OriginalLocation, GeneratedLocation } = require("devtools/server/actors/common"); +const { createValueGrip } = require("devtools/server/actors/object"); +const { ActorClassWithSpec, Arg, RetVal, method } = require("devtools/shared/protocol"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { assert, fetch } = DevToolsUtils; +const { joinURI } = require("devtools/shared/path"); +const promise = require("promise"); +const { defer, resolve, reject, all } = promise; +const { sourceSpec } = require("devtools/shared/specs/source"); + +loader.lazyRequireGetter(this, "SourceMapConsumer", "source-map", true); +loader.lazyRequireGetter(this, "SourceMapGenerator", "source-map", true); +loader.lazyRequireGetter(this, "mapURIToAddonID", "devtools/server/actors/utils/map-uri-to-addon-id"); + +function isEvalSource(source) { + let introType = source.introductionType; + // These are all the sources that are essentially eval-ed (either + // by calling eval or passing a string to one of these functions). + return (introType === "eval" || + introType === "Function" || + introType === "eventHandler" || + introType === "setTimeout" || + introType === "setInterval"); +} + +exports.isEvalSource = isEvalSource; + +function getSourceURL(source, window) { + if (isEvalSource(source)) { + // Eval sources have no urls, but they might have a `displayURL` + // created with the sourceURL pragma. If the introduction script + // is a non-eval script, generate an full absolute URL relative to it. + + if (source.displayURL && source.introductionScript && + !isEvalSource(source.introductionScript.source)) { + + if (source.introductionScript.source.url === "debugger eval code") { + if (window) { + // If this is a named eval script created from the console, make it + // relative to the current page. window is only available + // when we care about this. + return joinURI(window.location.href, source.displayURL); + } + } + else { + return joinURI(source.introductionScript.source.url, source.displayURL); + } + } + + return source.displayURL; + } + else if (source.url === "debugger eval code") { + // Treat code evaluated by the console as unnamed eval scripts + return null; + } + return source.url; +} + +exports.getSourceURL = getSourceURL; + +/** + * Resolve a URI back to physical file. + * + * Of course, this works only for URIs pointing to local resources. + * + * @param aURI + * URI to resolve + * @return + * resolved nsIURI + */ +function resolveURIToLocalPath(aURI) { + let resolved; + switch (aURI.scheme) { + case "jar": + case "file": + return aURI; + + case "chrome": + resolved = Cc["@mozilla.org/chrome/chrome-registry;1"]. + getService(Ci.nsIChromeRegistry).convertChromeURL(aURI); + return resolveURIToLocalPath(resolved); + + case "resource": + resolved = Cc["@mozilla.org/network/protocol;1?name=resource"]. + getService(Ci.nsIResProtocolHandler).resolveURI(aURI); + aURI = Services.io.newURI(resolved, null, null); + return resolveURIToLocalPath(aURI); + + default: + return null; + } +} + +/** + * A SourceActor provides information about the source of a script. There + * are two kinds of source actors: ones that represent real source objects, + * and ones that represent non-existant "original" sources when the real + * sources are sourcemapped. When a source is sourcemapped, actors are + * created for both the "generated" and "original" sources, and the client will + * only see the original sources. We separate these because there isn't + * a 1:1 mapping of generated to original sources; one generated source + * may represent N original sources, so we need to create N + 1 separate + * actors. + * + * There are 4 different scenarios for sources that you should + * understand: + * + * - A single non-sourcemapped source that is not inlined in HTML + * (separate JS file, eval'ed code, etc) + * - A single sourcemapped source which creates N original sources + * - An HTML page with multiple inline scripts, which are distinct + * sources, but should be represented as a single source + * - A pretty-printed source (which may or may not be an original + * sourcemapped source), which generates a sourcemap for itself + * + * The complexity of `SourceActor` and `ThreadSources` are to handle + * all of thise cases and hopefully internalize the complexities. + * + * @param Debugger.Source source + * The source object we are representing. + * @param ThreadActor thread + * The current thread actor. + * @param String originalUrl + * Optional. For sourcemapped urls, the original url this is representing. + * @param Debugger.Source generatedSource + * Optional, passed in when aSourceMap is also passed in. The generated + * source object that introduced this source. + * @param Boolean isInlineSource + * Optional. True if this is an inline source from a HTML or XUL page. + * @param String contentType + * Optional. The content type of this source, if immediately available. + */ +let SourceActor = ActorClassWithSpec(sourceSpec, { + typeName: "source", + + initialize: function ({ source, thread, originalUrl, generatedSource, + isInlineSource, contentType }) { + this._threadActor = thread; + this._originalUrl = originalUrl; + this._source = source; + this._generatedSource = generatedSource; + this._contentType = contentType; + this._isInlineSource = isInlineSource; + + this.onSource = this.onSource.bind(this); + this._invertSourceMap = this._invertSourceMap.bind(this); + this._encodeAndSetSourceMapURL = this._encodeAndSetSourceMapURL.bind(this); + this._getSourceText = this._getSourceText.bind(this); + + this._mapSourceToAddon(); + + if (this.threadActor.sources.isPrettyPrinted(this.url)) { + this._init = this.prettyPrint( + this.threadActor.sources.prettyPrintIndent(this.url) + ).then(null, error => { + DevToolsUtils.reportException("SourceActor", error); + }); + } else { + this._init = null; + } + }, + + get isSourceMapped() { + return !!(!this.isInlineSource && ( + this._originalURL || this._generatedSource || + this.threadActor.sources.isPrettyPrinted(this.url) + )); + }, + + get isInlineSource() { + return this._isInlineSource; + }, + + get threadActor() { return this._threadActor; }, + get sources() { return this._threadActor.sources; }, + get dbg() { return this.threadActor.dbg; }, + get source() { return this._source; }, + get generatedSource() { return this._generatedSource; }, + get breakpointActorMap() { return this.threadActor.breakpointActorMap; }, + get url() { + if (this.source) { + return getSourceURL(this.source, this.threadActor._parent.window); + } + return this._originalUrl; + }, + get addonID() { return this._addonID; }, + get addonPath() { return this._addonPath; }, + + get prettyPrintWorker() { + return this.threadActor.prettyPrintWorker; + }, + + form: function () { + let source = this.source || this.generatedSource; + // This might not have a source or a generatedSource because we + // treat HTML pages with inline scripts as a special SourceActor + // that doesn't have either + let introductionUrl = null; + if (source && source.introductionScript) { + introductionUrl = source.introductionScript.source.url; + } + + return { + actor: this.actorID, + generatedUrl: this.generatedSource ? this.generatedSource.url : null, + url: this.url ? this.url.split(" -> ").pop() : null, + addonID: this._addonID, + addonPath: this._addonPath, + isBlackBoxed: this.threadActor.sources.isBlackBoxed(this.url), + isPrettyPrinted: this.threadActor.sources.isPrettyPrinted(this.url), + isSourceMapped: this.isSourceMapped, + sourceMapURL: source ? source.sourceMapURL : null, + introductionUrl: introductionUrl ? introductionUrl.split(" -> ").pop() : null, + introductionType: source ? source.introductionType : null + }; + }, + + disconnect: function () { + if (this.registeredPool && this.registeredPool.sourceActors) { + delete this.registeredPool.sourceActors[this.actorID]; + } + }, + + _mapSourceToAddon: function () { + try { + var nsuri = Services.io.newURI(this.url.split(" -> ").pop(), null, null); + } + catch (e) { + // We can't do anything with an invalid URI + return; + } + + let localURI = resolveURIToLocalPath(nsuri); + if (!localURI) { + return; + } + + let id = mapURIToAddonID(localURI); + if (!id) { + return; + } + this._addonID = id; + + if (localURI instanceof Ci.nsIJARURI) { + // The path in the add-on is easy for jar: uris + this._addonPath = localURI.JAREntry; + } + else if (localURI instanceof Ci.nsIFileURL) { + // For file: uris walk up to find the last directory that is part of the + // add-on + let target = localURI.file; + let path = target.leafName; + + // We can assume that the directory containing the source file is part + // of the add-on + let root = target.parent; + let file = root.parent; + while (file && mapURIToAddonID(Services.io.newFileURI(file))) { + path = root.leafName + "/" + path; + root = file; + file = file.parent; + } + + if (!file) { + const error = new Error("Could not find the root of the add-on for " + this.url); + DevToolsUtils.reportException("SourceActor.prototype._mapSourceToAddon", error); + return; + } + + this._addonPath = path; + } + }, + + _reportLoadSourceError: function (error, map = null) { + try { + DevToolsUtils.reportException("SourceActor", error); + + JSON.stringify(this.form(), null, 4).split(/\n/g) + .forEach(line => console.error("\t", line)); + + if (!map) { + return; + } + + console.error("\t", "source map's sourceRoot =", map.sourceRoot); + + console.error("\t", "source map's sources ="); + map.sources.forEach(s => { + let hasSourceContent = map.sourceContentFor(s, true); + console.error("\t\t", s, "\t", + hasSourceContent ? "has source content" : "no source content"); + }); + + console.error("\t", "source map's sourcesContent ="); + map.sourcesContent.forEach(c => { + if (c.length > 80) { + c = c.slice(0, 77) + "..."; + } + c = c.replace(/\n/g, "\\n"); + console.error("\t\t", c); + }); + } catch (e) { } + }, + + _getSourceText: function () { + let toResolvedContent = t => ({ + content: t, + contentType: this._contentType + }); + + let genSource = this.generatedSource || this.source; + return this.threadActor.sources.fetchSourceMap(genSource).then(map => { + if (map) { + try { + let sourceContent = map.sourceContentFor(this.url); + if (sourceContent) { + return toResolvedContent(sourceContent); + } + } catch (error) { + this._reportLoadSourceError(error, map); + throw error; + } + } + + // Use `source.text` if it exists, is not the "no source" string, and + // the content type of the source is JavaScript or it is synthesized + // wasm. It will be "no source" if the Debugger API wasn't able to load + // the source because sources were discarded + // (javascript.options.discardSystemSource == true). Re-fetch non-JS + // sources to get the contentType from the headers. + if (this.source && + this.source.text !== "[no source]" && + this._contentType && + (this._contentType.indexOf("javascript") !== -1 || + this._contentType === "text/wasm")) { + return toResolvedContent(this.source.text); + } + else { + // Only load the HTML page source from cache (which exists when + // there are inline sources). Otherwise, we can't trust the + // cache because we are most likely here because we are + // fetching the original text for sourcemapped code, and the + // page hasn't requested it before (if it has, it was a + // previous debugging session). + let loadFromCache = this.isInlineSource; + + // Fetch the sources with the same principal as the original document + let win = this.threadActor._parent.window; + let principal, cacheKey; + // On xpcshell, we don't have a window but a Sandbox + if (!isWorker && win instanceof Ci.nsIDOMWindow) { + let webNav = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation); + let channel = webNav.currentDocumentChannel; + principal = channel.loadInfo.loadingPrincipal; + + // Retrieve the cacheKey in order to load POST requests from cache + // Note that chrome:// URLs don't support this interface. + if (loadFromCache && + webNav.currentDocumentChannel instanceof Ci.nsICacheInfoChannel) { + cacheKey = webNav.currentDocumentChannel.cacheKey; + assert( + cacheKey, + "Could not fetch the cacheKey from the related document." + ); + } + } + + let sourceFetched = fetch(this.url, { + principal, + cacheKey, + loadFromCache + }); + + // Record the contentType we just learned during fetching + return sourceFetched + .then(result => { + this._contentType = result.contentType; + return result; + }, error => { + this._reportLoadSourceError(error, map); + throw error; + }); + } + }); + }, + + /** + * Get all executable lines from the current source + * @return Array - Executable lines of the current script + **/ + getExecutableLines: function () { + function sortLines(lines) { + // Converting the Set into an array + lines = [...lines]; + lines.sort((a, b) => { + return a - b; + }); + return lines; + } + + if (this.generatedSource) { + return this.threadActor.sources.getSourceMap(this.generatedSource).then(sm => { + let lines = new Set(); + + // Position of executable lines in the generated source + let offsets = this.getExecutableOffsets(this.generatedSource, false); + for (let offset of offsets) { + let {line, source: sourceUrl} = sm.originalPositionFor({ + line: offset.lineNumber, + column: offset.columnNumber + }); + + if (sourceUrl === this.url) { + lines.add(line); + } + } + + return sortLines(lines); + }); + } + + let lines = this.getExecutableOffsets(this.source, true); + return sortLines(lines); + }, + + /** + * Extract all executable offsets from the given script + * @param String url - extract offsets of the script with this url + * @param Boolean onlyLine - will return only the line number + * @return Set - Executable offsets/lines of the script + **/ + getExecutableOffsets: function (source, onlyLine) { + let offsets = new Set(); + for (let s of this.dbg.findScripts({ source })) { + for (let offset of s.getAllColumnOffsets()) { + offsets.add(onlyLine ? offset.lineNumber : offset); + } + } + + return offsets; + }, + + /** + * Handler for the "source" packet. + */ + onSource: function () { + return resolve(this._init) + .then(this._getSourceText) + .then(({ content, contentType }) => { + return { + source: createValueGrip(content, this.threadActor.threadLifetimePool, + this.threadActor.objectGrip), + contentType: contentType + }; + }) + .then(null, aError => { + reportError(aError, "Got an exception during SA_onSource: "); + throw new Error("Could not load the source for " + this.url + ".\n" + + DevToolsUtils.safeErrorString(aError)); + }); + }, + + /** + * Handler for the "prettyPrint" packet. + */ + prettyPrint: function (indent) { + this.threadActor.sources.prettyPrint(this.url, indent); + return this._getSourceText() + .then(this._sendToPrettyPrintWorker(indent)) + .then(this._invertSourceMap) + .then(this._encodeAndSetSourceMapURL) + .then(() => { + // We need to reset `_init` now because we have already done the work of + // pretty printing, and don't want onSource to wait forever for + // initialization to complete. + this._init = null; + }) + .then(this.onSource) + .then(null, error => { + this.disablePrettyPrint(); + throw new Error(DevToolsUtils.safeErrorString(error)); + }); + }, + + /** + * Return a function that sends a request to the pretty print worker, waits on + * the worker's response, and then returns the pretty printed code. + * + * @param Number aIndent + * The number of spaces to indent by the code by, when we send the + * request to the pretty print worker. + * @returns Function + * Returns a function which takes an AST, and returns a promise that + * is resolved with `{ code, mappings }` where `code` is the pretty + * printed code, and `mappings` is an array of source mappings. + */ + _sendToPrettyPrintWorker: function (aIndent) { + return ({ content }) => { + return this.prettyPrintWorker.performTask("pretty-print", { + url: this.url, + indent: aIndent, + source: content + }); + }; + }, + + /** + * Invert a source map. So if a source map maps from a to b, return a new + * source map from b to a. We need to do this because the source map we get + * from _generatePrettyCodeAndMap goes the opposite way we want it to for + * debugging. + * + * Note that the source map is modified in place. + */ + _invertSourceMap: function ({ code, mappings }) { + const generator = new SourceMapGenerator({ file: this.url }); + return DevToolsUtils.yieldingEach(mappings._array, m => { + let mapping = { + generated: { + line: m.originalLine, + column: m.originalColumn + } + }; + if (m.source) { + mapping.source = m.source; + mapping.original = { + line: m.generatedLine, + column: m.generatedColumn + }; + mapping.name = m.name; + } + generator.addMapping(mapping); + }).then(() => { + generator.setSourceContent(this.url, code); + let consumer = SourceMapConsumer.fromSourceMap(generator); + + return { + code: code, + map: consumer + }; + }); + }, + + /** + * Save the source map back to our thread's ThreadSources object so that + * stepping, breakpoints, debugger statements, etc can use it. If we are + * pretty printing a source mapped source, we need to compose the existing + * source map with our new one. + */ + _encodeAndSetSourceMapURL: function ({ map: sm }) { + let source = this.generatedSource || this.source; + let sources = this.threadActor.sources; + + return sources.getSourceMap(source).then(prevMap => { + if (prevMap) { + // Compose the source maps + this._oldSourceMapping = { + url: source.sourceMapURL, + map: prevMap + }; + + prevMap = SourceMapGenerator.fromSourceMap(prevMap); + prevMap.applySourceMap(sm, this.url); + sm = SourceMapConsumer.fromSourceMap(prevMap); + } + + let sources = this.threadActor.sources; + sources.clearSourceMapCache(source.sourceMapURL); + sources.setSourceMapHard(source, null, sm); + }); + }, + + /** + * Handler for the "disablePrettyPrint" packet. + */ + disablePrettyPrint: function () { + let source = this.generatedSource || this.source; + let sources = this.threadActor.sources; + let sm = sources.getSourceMap(source); + + sources.clearSourceMapCache(source.sourceMapURL, { hard: true }); + + if (this._oldSourceMapping) { + sources.setSourceMapHard(source, + this._oldSourceMapping.url, + this._oldSourceMapping.map); + this._oldSourceMapping = null; + } + + this.threadActor.sources.disablePrettyPrint(this.url); + return this.onSource(); + }, + + /** + * Handler for the "blackbox" packet. + */ + blackbox: function () { + this.threadActor.sources.blackBox(this.url); + if (this.threadActor.state == "paused" + && this.threadActor.youngestFrame + && this.threadActor.youngestFrame.script.url == this.url) { + return true; + } + return false; + }, + + /** + * Handler for the "unblackbox" packet. + */ + unblackbox: function () { + this.threadActor.sources.unblackBox(this.url); + }, + + /** + * Handle a request to set a breakpoint. + * + * @param Number line + * Line to break on. + * @param Number column + * Column to break on. + * @param String condition + * A condition which must be true for breakpoint to be hit. + * @param Boolean noSliding + * If true, disables breakpoint sliding. + * + * @returns Promise + * A promise that resolves to a JSON object representing the + * response. + */ + setBreakpoint: function (line, column, condition, noSliding) { + if (this.threadActor.state !== "paused") { + throw { + error: "wrongState", + message: "Cannot set breakpoint while debuggee is running." + }; + } + + let location = new OriginalLocation(this, line, column); + return this._getOrCreateBreakpointActor( + location, + condition, + noSliding + ).then((actor) => { + let response = { + actor: actor.actorID, + isPending: actor.isPending + }; + + let actualLocation = actor.originalLocation; + if (!actualLocation.equals(location)) { + response.actualLocation = actualLocation.toJSON(); + } + + return response; + }); + }, + + /** + * Get or create a BreakpointActor for the given location in the original + * source, and ensure it is set as a breakpoint handler on all scripts that + * match the given location. + * + * @param OriginalLocation originalLocation + * An OriginalLocation representing the location of the breakpoint in + * the original source. + * @param String condition + * A string that is evaluated whenever the breakpoint is hit. If the + * string evaluates to false, the breakpoint is ignored. + * @param Boolean noSliding + * If true, disables breakpoint sliding. + * + * @returns BreakpointActor + * A BreakpointActor representing the breakpoint. + */ + _getOrCreateBreakpointActor: function (originalLocation, condition, noSliding) { + let actor = this.breakpointActorMap.getActor(originalLocation); + if (!actor) { + actor = new BreakpointActor(this.threadActor, originalLocation); + this.threadActor.threadLifetimePool.addActor(actor); + this.breakpointActorMap.setActor(originalLocation, actor); + } + + actor.condition = condition; + + return this._setBreakpoint(actor, noSliding); + }, + + /* + * Ensure the given BreakpointActor is set as a breakpoint handler on all + * scripts that match its location in the original source. + * + * If there are no scripts that match the location of the BreakpointActor, + * we slide its location to the next closest line (for line breakpoints) or + * column (for column breakpoint) that does. + * + * If breakpoint sliding fails, then either there are no scripts that contain + * any code for the given location, or they were all garbage collected before + * the debugger started running. We cannot distinguish between these two + * cases, so we insert the BreakpointActor in the BreakpointActorMap as + * a pending breakpoint. Whenever a new script is introduced, this method is + * called again for each pending breakpoint. + * + * @param BreakpointActor actor + * The BreakpointActor to be set as a breakpoint handler. + * @param Boolean noSliding + * If true, disables breakpoint sliding. + * + * @returns A Promise that resolves to the given BreakpointActor. + */ + _setBreakpoint: function (actor, noSliding) { + const { originalLocation } = actor; + const { originalLine, originalSourceActor } = originalLocation; + + if (!this.isSourceMapped) { + const generatedLocation = GeneratedLocation.fromOriginalLocation(originalLocation); + if (!this._setBreakpointAtGeneratedLocation(actor, generatedLocation) && + !noSliding) { + const query = { line: originalLine }; + // For most cases, we have a real source to query for. The + // only time we don't is for HTML pages. In that case we want + // to query for scripts in an HTML page based on its URL, as + // there could be several sources within an HTML page. + if (this.source) { + query.source = this.source; + } else { + query.url = this.url; + } + const scripts = this.dbg.findScripts(query); + + // Never do breakpoint sliding for column breakpoints. + // Additionally, never do breakpoint sliding if no scripts + // exist on this line. + // + // Sliding can go horribly wrong if we always try to find the + // next line with valid entry points in the entire file. + // Scripts may be completely GCed and we never knew they + // existed, so we end up sliding through whole functions to + // the user's bewilderment. + // + // We can slide reliably if any scripts exist, however, due + // to how scripts are kept alive. A parent Debugger.Script + // keeps all of its children alive, so as long as we have a + // valid script, we can slide through it and know we won't + // slide through any of its child scripts. Additionally, if a + // script gets GCed, that means that all parents scripts are + // GCed as well, and no scripts will exist on those lines + // anymore. We will never slide through a GCed script. + if (originalLocation.originalColumn || scripts.length === 0) { + return promise.resolve(actor); + } + + // Find the script that spans the largest amount of code to + // determine the bounds for sliding. + const largestScript = scripts.reduce((largestScript, script) => { + if (script.lineCount > largestScript.lineCount) { + return script; + } + return largestScript; + }); + const maxLine = largestScript.startLine + largestScript.lineCount - 1; + + let actualLine = originalLine; + for (; actualLine <= maxLine; actualLine++) { + const loc = new GeneratedLocation(this, actualLine); + if (this._setBreakpointAtGeneratedLocation(actor, loc)) { + break; + } + } + + // The above loop should never complete. We only did breakpoint sliding + // because we found scripts on the line we started from, + // which means there must be valid entry points somewhere + // within those scripts. + assert( + actualLine <= maxLine, + "Could not find any entry points to set a breakpoint on, " + + "even though I was told a script existed on the line I started " + + "the search with." + ); + + // Update the actor to use the new location (reusing a + // previous breakpoint if it already exists on that line). + const actualLocation = new OriginalLocation(originalSourceActor, actualLine); + const existingActor = this.breakpointActorMap.getActor(actualLocation); + this.breakpointActorMap.deleteActor(originalLocation); + if (existingActor) { + actor.delete(); + actor = existingActor; + } else { + actor.originalLocation = actualLocation; + this.breakpointActorMap.setActor(actualLocation, actor); + } + } + + return promise.resolve(actor); + } else { + return this.sources.getAllGeneratedLocations(originalLocation).then((generatedLocations) => { + this._setBreakpointAtAllGeneratedLocations( + actor, + generatedLocations + ); + + return actor; + }); + } + }, + + _setBreakpointAtAllGeneratedLocations: function (actor, generatedLocations) { + let success = false; + for (let generatedLocation of generatedLocations) { + if (this._setBreakpointAtGeneratedLocation( + actor, + generatedLocation + )) { + success = true; + } + } + return success; + }, + + /* + * Ensure the given BreakpointActor is set as breakpoint handler on all + * scripts that match the given location in the generated source. + * + * @param BreakpointActor actor + * The BreakpointActor to be set as a breakpoint handler. + * @param GeneratedLocation generatedLocation + * A GeneratedLocation representing the location in the generated + * source for which the given BreakpointActor is to be set as a + * breakpoint handler. + * + * @returns A Boolean that is true if the BreakpointActor was set as a + * breakpoint handler on at least one script, and false otherwise. + */ + _setBreakpointAtGeneratedLocation: function (actor, generatedLocation) { + let { + generatedSourceActor, + generatedLine, + generatedColumn, + generatedLastColumn + } = generatedLocation; + + // Find all scripts that match the given source actor and line + // number. + const query = { line: generatedLine }; + if (generatedSourceActor.source) { + query.source = generatedSourceActor.source; + } else { + query.url = generatedSourceActor.url; + } + let scripts = this.dbg.findScripts(query); + + scripts = scripts.filter((script) => !actor.hasScript(script)); + + // Find all entry points that correspond to the given location. + let entryPoints = []; + if (generatedColumn === undefined) { + // This is a line breakpoint, so we are interested in all offsets + // that correspond to the given line number. + for (let script of scripts) { + let offsets = script.getLineOffsets(generatedLine); + if (offsets.length > 0) { + entryPoints.push({ script, offsets }); + } + } + } else { + // This is a column breakpoint, so we are interested in all column + // offsets that correspond to the given line *and* column number. + for (let script of scripts) { + let columnToOffsetMap = script.getAllColumnOffsets() + .filter(({ lineNumber }) => { + return lineNumber === generatedLine; + }); + for (let { columnNumber: column, offset } of columnToOffsetMap) { + if (column >= generatedColumn && column <= generatedLastColumn) { + entryPoints.push({ script, offsets: [offset] }); + } + } + } + } + + if (entryPoints.length === 0) { + return false; + } + setBreakpointAtEntryPoints(actor, entryPoints); + return true; + } +}); + +exports.SourceActor = SourceActor; diff --git a/devtools/server/actors/storage.js b/devtools/server/actors/storage.js new file mode 100644 index 000000000..572cd6b68 --- /dev/null +++ b/devtools/server/actors/storage.js @@ -0,0 +1,2542 @@ +/* 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 {Cc, Ci, Cu, CC} = require("chrome"); +const events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const {LongStringActor} = require("devtools/server/actors/string"); +const {DebuggerServer} = require("devtools/server/main"); +const Services = require("Services"); +const promise = require("promise"); +const {isWindowIncluded} = require("devtools/shared/layout/utils"); +const specs = require("devtools/shared/specs/storage"); +const { Task } = require("devtools/shared/task"); + +loader.lazyImporter(this, "OS", "resource://gre/modules/osfile.jsm"); +loader.lazyImporter(this, "Sqlite", "resource://gre/modules/Sqlite.jsm"); + +// We give this a funny name to avoid confusion with the global +// indexedDB. +loader.lazyGetter(this, "indexedDBForStorage", () => { + // On xpcshell, we can't instantiate indexedDB without crashing + try { + let sandbox + = Cu.Sandbox(CC("@mozilla.org/systemprincipal;1", "nsIPrincipal")(), + {wantGlobalProperties: ["indexedDB"]}); + return sandbox.indexedDB; + } catch (e) { + return {}; + } +}); + +// Maximum number of cookies/local storage key-value-pairs that can be sent +// over the wire to the client in one request. +const MAX_STORE_OBJECT_COUNT = 50; +// Delay for the batch job that sends the accumulated update packets to the +// client (ms). +const BATCH_DELAY = 200; + +// MAX_COOKIE_EXPIRY should be 2^63-1, but JavaScript can't handle that +// precision. +const MAX_COOKIE_EXPIRY = Math.pow(2, 62); + +// A RegExp for characters that cannot appear in a file/directory name. This is +// used to sanitize the host name for indexed db to lookup whether the file is +// present in <profileDir>/storage/default/ location +var illegalFileNameCharacters = [ + "[", + // Control characters \001 to \036 + "\\x00-\\x24", + // Special characters + "/:*?\\\"<>|\\\\", + "]" +].join(""); +var ILLEGAL_CHAR_REGEX = new RegExp(illegalFileNameCharacters, "g"); + +// Holder for all the registered storage actors. +var storageTypePool = new Map(); + +/** + * An async method equivalent to setTimeout but using Promises + * + * @param {number} time + * The wait time in milliseconds. + */ +function sleep(time) { + let deferred = promise.defer(); + + setTimeout(() => { + deferred.resolve(null); + }, time); + + return deferred.promise; +} + +// Helper methods to create a storage actor. +var StorageActors = {}; + +/** + * Creates a default object with the common methods required by all storage + * actors. + * + * This default object is missing a couple of required methods that should be + * implemented seperately for each actor. They are namely: + * - observe : Method which gets triggered on the notificaiton of the watched + * topic. + * - getNamesForHost : Given a host, get list of all known store names. + * - getValuesForHost : Given a host (and optianally a name) get all known + * store objects. + * - toStoreObject : Given a store object, convert it to the required format + * so that it can be transferred over wire. + * - populateStoresForHost : Given a host, populate the map of all store + * objects for it + * - getFields: Given a subType(optional), get an array of objects containing + * column field info. The info includes, + * "name" is name of colume key. + * "editable" is 1 means editable field; 0 means uneditable. + * + * @param {string} typeName + * The typeName of the actor. + * @param {string} observationTopic + * The topic which this actor listens to via Notification Observers. + */ +StorageActors.defaults = function (typeName, observationTopic) { + return { + typeName: typeName, + + get conn() { + return this.storageActor.conn; + }, + + /** + * Returns a list of currently knwon hosts for the target window. This list + * contains unique hosts from the window + all inner windows. + */ + get hosts() { + let hosts = new Set(); + for (let {location} of this.storageActor.windows) { + hosts.add(this.getHostName(location)); + } + return hosts; + }, + + /** + * Returns all the windows present on the page. Includes main window + inner + * iframe windows. + */ + get windows() { + return this.storageActor.windows; + }, + + /** + * Converts the window.location object into host. + */ + getHostName(location) { + return location.hostname || location.href; + }, + + initialize(storageActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.storageActor = storageActor; + + this.populateStoresForHosts(); + if (observationTopic) { + Services.obs.addObserver(this, observationTopic, false); + } + this.onWindowReady = this.onWindowReady.bind(this); + this.onWindowDestroyed = this.onWindowDestroyed.bind(this); + events.on(this.storageActor, "window-ready", this.onWindowReady); + events.on(this.storageActor, "window-destroyed", this.onWindowDestroyed); + }, + + destroy() { + if (observationTopic) { + Services.obs.removeObserver(this, observationTopic, false); + } + events.off(this.storageActor, "window-ready", this.onWindowReady); + events.off(this.storageActor, "window-destroyed", this.onWindowDestroyed); + + this.hostVsStores.clear(); + this.storageActor = null; + }, + + getNamesForHost(host) { + return [...this.hostVsStores.get(host).keys()]; + }, + + getValuesForHost(host, name) { + if (name) { + return [this.hostVsStores.get(host).get(name)]; + } + return [...this.hostVsStores.get(host).values()]; + }, + + getObjectsSize(host, names) { + return names.length; + }, + + /** + * When a new window is added to the page. This generally means that a new + * iframe is created, or the current window is completely reloaded. + * + * @param {window} window + * The window which was added. + */ + onWindowReady: Task.async(function* (window) { + let host = this.getHostName(window.location); + if (!this.hostVsStores.has(host)) { + yield this.populateStoresForHost(host, window); + let data = {}; + data[host] = this.getNamesForHost(host); + this.storageActor.update("added", typeName, data); + } + }), + + /** + * When a window is removed from the page. This generally means that an + * iframe was removed, or the current window reload is triggered. + * + * @param {window} window + * The window which was removed. + */ + onWindowDestroyed(window) { + if (!window.location) { + // Nothing can be done if location object is null + return; + } + let host = this.getHostName(window.location); + if (!this.hosts.has(host)) { + this.hostVsStores.delete(host); + let data = {}; + data[host] = []; + this.storageActor.update("deleted", typeName, data); + } + }, + + form(form, detail) { + if (detail === "actorid") { + return this.actorID; + } + + let hosts = {}; + for (let host of this.hosts) { + hosts[host] = []; + } + + return { + actor: this.actorID, + hosts: hosts + }; + }, + + /** + * Populates a map of known hosts vs a map of stores vs value. + */ + populateStoresForHosts() { + this.hostVsStores = new Map(); + for (let host of this.hosts) { + this.populateStoresForHost(host); + } + }, + + /** + * Returns a list of requested store objects. Maximum values returned are + * MAX_STORE_OBJECT_COUNT. This method returns paginated values whose + * starting index and total size can be controlled via the options object + * + * @param {string} host + * The host name for which the store values are required. + * @param {array:string} names + * Array containing the names of required store objects. Empty if all + * items are required. + * @param {object} options + * Additional options for the request containing following + * properties: + * - offset {number} : The begin index of the returned array amongst + * the total values + * - size {number} : The number of values required. + * - sortOn {string} : The values should be sorted on this property. + * - index {string} : In case of indexed db, the IDBIndex to be used + * for fetching the values. + * + * @return {object} An object containing following properties: + * - offset - The actual offset of the returned array. This might + * be different from the requested offset if that was + * invalid + * - total - The total number of entries possible. + * - data - The requested values. + */ + getStoreObjects: Task.async(function* (host, names, options = {}) { + let offset = options.offset || 0; + let size = options.size || MAX_STORE_OBJECT_COUNT; + if (size > MAX_STORE_OBJECT_COUNT) { + size = MAX_STORE_OBJECT_COUNT; + } + let sortOn = options.sortOn || "name"; + + let toReturn = { + offset: offset, + total: 0, + data: [] + }; + + let principal = null; + if (this.typeName === "indexedDB") { + // We only acquire principal when the type of the storage is indexedDB + // because the principal only matters the indexedDB. + let win = this.storageActor.getWindowFromHost(host); + if (win) { + principal = win.document.nodePrincipal; + } + } + + if (names) { + for (let name of names) { + let values = yield this.getValuesForHost(host, name, options, + this.hostVsStores, principal); + + let {result, objectStores} = values; + + if (result && typeof result.objectsSize !== "undefined") { + for (let {key, count} of result.objectsSize) { + this.objectsSize[key] = count; + } + } + + if (result) { + toReturn.data.push(...result.data); + } else if (objectStores) { + toReturn.data.push(...objectStores); + } else { + toReturn.data.push(...values); + } + } + toReturn.total = this.getObjectsSize(host, names, options); + if (offset > toReturn.total) { + // In this case, toReturn.data is an empty array. + toReturn.offset = toReturn.total; + toReturn.data = []; + } else { + toReturn.data = toReturn.data.sort((a, b) => { + return a[sortOn] - b[sortOn]; + }).slice(offset, offset + size).map(a => this.toStoreObject(a)); + } + } else { + let obj = yield this.getValuesForHost(host, undefined, undefined, + this.hostVsStores, principal); + if (obj.dbs) { + obj = obj.dbs; + } + + toReturn.total = obj.length; + if (offset > toReturn.total) { + // In this case, toReturn.data is an empty array. + toReturn.offset = offset = toReturn.total; + toReturn.data = []; + } else { + toReturn.data = obj.sort((a, b) => { + return a[sortOn] - b[sortOn]; + }).slice(offset, offset + size) + .map(object => this.toStoreObject(object)); + } + } + + return toReturn; + }) + }; +}; + +/** + * Creates an actor and its corresponding front and registers it to the Storage + * Actor. + * + * @See StorageActors.defaults() + * + * @param {object} options + * Options required by StorageActors.defaults method which are : + * - typeName {string} + * The typeName of the actor. + * - observationTopic {string} + * The topic which this actor listens to via + * Notification Observers. + * @param {object} overrides + * All the methods which you want to be different from the ones in + * StorageActors.defaults method plus the required ones described there. + */ +StorageActors.createActor = function (options = {}, overrides = {}) { + let actorObject = StorageActors.defaults( + options.typeName, + options.observationTopic || null + ); + for (let key in overrides) { + actorObject[key] = overrides[key]; + } + + let actorSpec = specs.childSpecs[options.typeName]; + let actor = protocol.ActorClassWithSpec(actorSpec, actorObject); + storageTypePool.set(actorObject.typeName, actor); +}; + +/** + * The Cookies actor and front. + */ +StorageActors.createActor({ + typeName: "cookies" +}, { + initialize(storageActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.storageActor = storageActor; + + this.maybeSetupChildProcess(); + this.populateStoresForHosts(); + this.addCookieObservers(); + + this.onWindowReady = this.onWindowReady.bind(this); + this.onWindowDestroyed = this.onWindowDestroyed.bind(this); + events.on(this.storageActor, "window-ready", this.onWindowReady); + events.on(this.storageActor, "window-destroyed", this.onWindowDestroyed); + }, + + destroy() { + this.hostVsStores.clear(); + + // We need to remove the cookie listeners early in E10S mode so we need to + // use a conditional here to ensure that we only attempt to remove them in + // single process mode. + if (!DebuggerServer.isInChildProcess) { + this.removeCookieObservers(); + } + + events.off(this.storageActor, "window-ready", this.onWindowReady); + events.off(this.storageActor, "window-destroyed", this.onWindowDestroyed); + + this._pendingResponse = this.storageActor = null; + }, + + /** + * Given a cookie object, figure out all the matching hosts from the page that + * the cookie belong to. + */ + getMatchingHosts(cookies) { + if (!cookies.length) { + cookies = [cookies]; + } + let hosts = new Set(); + for (let host of this.hosts) { + for (let cookie of cookies) { + if (this.isCookieAtHost(cookie, host)) { + hosts.add(host); + } + } + } + return [...hosts]; + }, + + /** + * Given a cookie object and a host, figure out if the cookie is valid for + * that host. + */ + isCookieAtHost(cookie, host) { + if (cookie.host == null) { + return host == null; + } + if (cookie.host.startsWith(".")) { + return ("." + host).endsWith(cookie.host); + } + if (cookie.host === "") { + return host.startsWith("file://" + cookie.path); + } + return cookie.host == host; + }, + + toStoreObject(cookie) { + if (!cookie) { + return null; + } + + return { + name: cookie.name, + path: cookie.path || "", + host: cookie.host || "", + + // because expires is in seconds + expires: (cookie.expires || 0) * 1000, + + // because it is in micro seconds + creationTime: cookie.creationTime / 1000, + + // - do - + lastAccessed: cookie.lastAccessed / 1000, + value: new LongStringActor(this.conn, cookie.value || ""), + isDomain: cookie.isDomain, + isSecure: cookie.isSecure, + isHttpOnly: cookie.isHttpOnly + }; + }, + + populateStoresForHost(host) { + this.hostVsStores.set(host, new Map()); + let doc = this.storageActor.document; + + let cookies = this.getCookiesFromHost(host, doc.nodePrincipal + .originAttributes); + + for (let cookie of cookies) { + if (this.isCookieAtHost(cookie, host)) { + this.hostVsStores.get(host).set(cookie.name, cookie); + } + } + }, + + /** + * Notification observer for "cookie-change". + * + * @param subject + * {Cookie|[Array]} A JSON parsed object containing either a single + * cookie representation or an array. Array is only in case of + * a "batch-deleted" action. + * @param {string} topic + * The topic of the notification. + * @param {string} action + * Additional data associated with the notification. Its the type of + * cookie change in the "cookie-change" topic. + */ + onCookieChanged(subject, topic, action) { + if (topic !== "cookie-changed" || + !this.storageActor || + !this.storageActor.windows) { + return null; + } + + let hosts = this.getMatchingHosts(subject); + let data = {}; + + switch (action) { + case "added": + case "changed": + if (hosts.length) { + for (let host of hosts) { + this.hostVsStores.get(host).set(subject.name, subject); + data[host] = [subject.name]; + } + this.storageActor.update(action, "cookies", data); + } + break; + + case "deleted": + if (hosts.length) { + for (let host of hosts) { + this.hostVsStores.get(host).delete(subject.name); + data[host] = [subject.name]; + } + this.storageActor.update("deleted", "cookies", data); + } + break; + + case "batch-deleted": + if (hosts.length) { + for (let host of hosts) { + let stores = []; + for (let cookie of subject) { + this.hostVsStores.get(host).delete(cookie.name); + stores.push(cookie.name); + } + data[host] = stores; + } + this.storageActor.update("deleted", "cookies", data); + } + break; + + case "cleared": + if (hosts.length) { + for (let host of hosts) { + data[host] = []; + } + this.storageActor.update("cleared", "cookies", data); + } + break; + } + return null; + }, + + getFields: Task.async(function* () { + return [ + { name: "name", editable: 1}, + { name: "path", editable: 1}, + { name: "host", editable: 1}, + { name: "expires", editable: 1}, + { name: "lastAccessed", editable: 0}, + { name: "value", editable: 1}, + { name: "isDomain", editable: 0}, + { name: "isSecure", editable: 1}, + { name: "isHttpOnly", editable: 1} + ]; + }), + + /** + * Pass the editItem command from the content to the chrome process. + * + * @param {Object} data + * See editCookie() for format details. + */ + editItem: Task.async(function* (data) { + let doc = this.storageActor.document; + data.originAttributes = doc.nodePrincipal + .originAttributes; + this.editCookie(data); + }), + + removeItem: Task.async(function* (host, name) { + let doc = this.storageActor.document; + this.removeCookie(host, name, doc.nodePrincipal + .originAttributes); + }), + + removeAll: Task.async(function* (host, domain) { + let doc = this.storageActor.document; + this.removeAllCookies(host, domain, doc.nodePrincipal + .originAttributes); + }), + + maybeSetupChildProcess() { + cookieHelpers.onCookieChanged = this.onCookieChanged.bind(this); + + if (!DebuggerServer.isInChildProcess) { + this.getCookiesFromHost = + cookieHelpers.getCookiesFromHost.bind(cookieHelpers); + this.addCookieObservers = + cookieHelpers.addCookieObservers.bind(cookieHelpers); + this.removeCookieObservers = + cookieHelpers.removeCookieObservers.bind(cookieHelpers); + this.editCookie = + cookieHelpers.editCookie.bind(cookieHelpers); + this.removeCookie = + cookieHelpers.removeCookie.bind(cookieHelpers); + this.removeAllCookies = + cookieHelpers.removeAllCookies.bind(cookieHelpers); + return; + } + + const { sendSyncMessage, addMessageListener } = + this.conn.parentMessageManager; + + this.conn.setupInParent({ + module: "devtools/server/actors/storage", + setupParent: "setupParentProcessForCookies" + }); + + this.getCookiesFromHost = + callParentProcess.bind(null, "getCookiesFromHost"); + this.addCookieObservers = + callParentProcess.bind(null, "addCookieObservers"); + this.removeCookieObservers = + callParentProcess.bind(null, "removeCookieObservers"); + this.editCookie = + callParentProcess.bind(null, "editCookie"); + this.removeCookie = + callParentProcess.bind(null, "removeCookie"); + this.removeAllCookies = + callParentProcess.bind(null, "removeAllCookies"); + + addMessageListener("debug:storage-cookie-request-child", + cookieHelpers.handleParentRequest); + + function callParentProcess(methodName, ...args) { + let reply = sendSyncMessage("debug:storage-cookie-request-parent", { + method: methodName, + args: args + }); + + if (reply.length === 0) { + console.error("ERR_DIRECTOR_CHILD_NO_REPLY from " + methodName); + } else if (reply.length > 1) { + console.error("ERR_DIRECTOR_CHILD_MULTIPLE_REPLIES from " + methodName); + } + + let result = reply[0]; + + if (methodName === "getCookiesFromHost") { + return JSON.parse(result); + } + + return result; + } + }, +}); + +var cookieHelpers = { + getCookiesFromHost(host, originAttributes) { + // Local files have no host. + if (host.startsWith("file:///")) { + host = ""; + } + + let cookies = Services.cookies.getCookiesFromHost(host, originAttributes); + let store = []; + + while (cookies.hasMoreElements()) { + let cookie = cookies.getNext().QueryInterface(Ci.nsICookie2); + + store.push(cookie); + } + + return store; + }, + + /** + * Apply the results of a cookie edit. + * + * @param {Object} data + * An object in the following format: + * { + * host: "http://www.mozilla.org", + * field: "value", + * key: "name", + * oldValue: "%7BHello%7D", + * newValue: "%7BHelloo%7D", + * items: { + * name: "optimizelyBuckets", + * path: "/", + * host: ".mozilla.org", + * expires: "Mon, 02 Jun 2025 12:37:37 GMT", + * creationTime: "Tue, 18 Nov 2014 16:21:18 GMT", + * lastAccessed: "Wed, 17 Feb 2016 10:06:23 GMT", + * value: "%7BHelloo%7D", + * isDomain: "true", + * isSecure: "false", + * isHttpOnly: "false" + * } + * } + */ + editCookie(data) { + let {field, oldValue, newValue} = data; + let origName = field === "name" ? oldValue : data.items.name; + let origHost = field === "host" ? oldValue : data.items.host; + let origPath = field === "path" ? oldValue : data.items.path; + let cookie = null; + + let enumerator = Services.cookies.getCookiesFromHost(origHost, data.originAttributes || {}); + while (enumerator.hasMoreElements()) { + let nsiCookie = enumerator.getNext().QueryInterface(Ci.nsICookie2); + if (nsiCookie.name === origName && nsiCookie.host === origHost) { + cookie = { + host: nsiCookie.host, + path: nsiCookie.path, + name: nsiCookie.name, + value: nsiCookie.value, + isSecure: nsiCookie.isSecure, + isHttpOnly: nsiCookie.isHttpOnly, + isSession: nsiCookie.isSession, + expires: nsiCookie.expires, + originAttributes: nsiCookie.originAttributes + }; + break; + } + } + + if (!cookie) { + return; + } + + // If the date is expired set it for 1 minute in the future. + let now = new Date(); + if (!cookie.isSession && (cookie.expires * 1000) <= now) { + let tenSecondsFromNow = (now.getTime() + 10 * 1000) / 1000; + + cookie.expires = tenSecondsFromNow; + } + + switch (field) { + case "isSecure": + case "isHttpOnly": + case "isSession": + newValue = newValue === "true"; + break; + + case "expires": + newValue = Date.parse(newValue) / 1000; + + if (isNaN(newValue)) { + newValue = MAX_COOKIE_EXPIRY; + } + break; + + case "host": + case "name": + case "path": + // Remove the edited cookie. + Services.cookies.remove(origHost, origName, origPath, + false, cookie.originAttributes); + break; + } + + // Apply changes. + cookie[field] = newValue; + + // cookie.isSession is not always set correctly on session cookies so we + // need to trust cookie.expires instead. + cookie.isSession = !cookie.expires; + + // Add the edited cookie. + Services.cookies.add( + cookie.host, + cookie.path, + cookie.name, + cookie.value, + cookie.isSecure, + cookie.isHttpOnly, + cookie.isSession, + cookie.isSession ? MAX_COOKIE_EXPIRY : cookie.expires, + cookie.originAttributes + ); + }, + + _removeCookies(host, opts = {}) { + function hostMatches(cookieHost, matchHost) { + if (cookieHost == null) { + return matchHost == null; + } + if (cookieHost.startsWith(".")) { + return ("." + matchHost).endsWith(cookieHost); + } + return cookieHost == host; + } + + let enumerator = Services.cookies.getCookiesFromHost(host, opts.originAttributes || {}); + while (enumerator.hasMoreElements()) { + let cookie = enumerator.getNext().QueryInterface(Ci.nsICookie2); + if (hostMatches(cookie.host, host) && + (!opts.name || cookie.name === opts.name) && + (!opts.domain || cookie.host === opts.domain)) { + Services.cookies.remove( + cookie.host, + cookie.name, + cookie.path, + false, + cookie.originAttributes + ); + } + } + }, + + removeCookie(host, name, originAttributes) { + if (name !== undefined) { + this._removeCookies(host, { name, originAttributes }); + } + }, + + removeAllCookies(host, domain, originAttributes) { + this._removeCookies(host, { domain, originAttributes }); + }, + + addCookieObservers() { + Services.obs.addObserver(cookieHelpers, "cookie-changed", false); + return null; + }, + + removeCookieObservers() { + Services.obs.removeObserver(cookieHelpers, "cookie-changed", false); + return null; + }, + + observe(subject, topic, data) { + if (!subject) { + return; + } + + switch (topic) { + case "cookie-changed": + if (data === "batch-deleted") { + let cookiesNoInterface = subject.QueryInterface(Ci.nsIArray); + let cookies = []; + + for (let i = 0; i < cookiesNoInterface.length; i++) { + let cookie = cookiesNoInterface.queryElementAt(i, Ci.nsICookie2); + cookies.push(cookie); + } + cookieHelpers.onCookieChanged(cookies, topic, data); + + return; + } + + let cookie = subject.QueryInterface(Ci.nsICookie2); + cookieHelpers.onCookieChanged(cookie, topic, data); + break; + } + }, + + handleParentRequest(msg) { + switch (msg.json.method) { + case "onCookieChanged": + let [cookie, topic, data] = msg.data.args; + cookie = JSON.parse(cookie); + cookieHelpers.onCookieChanged(cookie, topic, data); + break; + } + }, + + handleChildRequest(msg) { + switch (msg.json.method) { + case "getCookiesFromHost": { + let host = msg.data.args[0]; + let originAttributes = msg.data.args[1]; + let cookies = cookieHelpers.getCookiesFromHost(host, originAttributes); + return JSON.stringify(cookies); + } + case "addCookieObservers": { + return cookieHelpers.addCookieObservers(); + } + case "removeCookieObservers": { + return cookieHelpers.removeCookieObservers(); + } + case "editCookie": { + let rowdata = msg.data.args[0]; + return cookieHelpers.editCookie(rowdata); + } + case "removeCookie": { + let host = msg.data.args[0]; + let name = msg.data.args[1]; + let originAttributes = msg.data.args[2]; + return cookieHelpers.removeCookie(host, name, originAttributes); + } + case "removeAllCookies": { + let host = msg.data.args[0]; + let domain = msg.data.args[1]; + let originAttributes = msg.data.args[2]; + return cookieHelpers.removeAllCookies(host, domain, originAttributes); + } + default: + console.error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD", msg.json.method); + throw new Error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD"); + } + }, +}; + +/** + * E10S parent/child setup helpers + */ + +exports.setupParentProcessForCookies = function ({ mm, prefix }) { + cookieHelpers.onCookieChanged = + callChildProcess.bind(null, "onCookieChanged"); + + // listen for director-script requests from the child process + setMessageManager(mm); + + function callChildProcess(methodName, ...args) { + if (methodName === "onCookieChanged") { + args[0] = JSON.stringify(args[0]); + } + + try { + mm.sendAsyncMessage("debug:storage-cookie-request-child", { + method: methodName, + args: args + }); + } catch (e) { + // We may receive a NS_ERROR_NOT_INITIALIZED if the target window has + // been closed. This can legitimately happen in between test runs. + } + } + + function setMessageManager(newMM) { + if (mm) { + mm.removeMessageListener("debug:storage-cookie-request-parent", + cookieHelpers.handleChildRequest); + } + mm = newMM; + if (mm) { + mm.addMessageListener("debug:storage-cookie-request-parent", + cookieHelpers.handleChildRequest); + } + } + + return { + onBrowserSwap: setMessageManager, + onDisconnected: () => { + // Although "disconnected-from-child" implies that the child is already + // disconnected this is not the case. The disconnection takes place after + // this method has finished. This gives us chance to clean up items within + // the parent process e.g. observers. + cookieHelpers.removeCookieObservers(); + setMessageManager(null); + } + }; +}; + +/** + * Helper method to create the overriden object required in + * StorageActors.createActor for Local Storage and Session Storage. + * This method exists as both Local Storage and Session Storage have almost + * identical actors. + */ +function getObjectForLocalOrSessionStorage(type) { + return { + getNamesForHost(host) { + let storage = this.hostVsStores.get(host); + return storage ? Object.keys(storage) : []; + }, + + getValuesForHost(host, name) { + let storage = this.hostVsStores.get(host); + if (!storage) { + return []; + } + if (name) { + let value = storage ? storage.getItem(name) : null; + return [{ name, value }]; + } + if (!storage) { + return []; + } + return Object.keys(storage).map(key => ({ + name: key, + value: storage.getItem(key) + })); + }, + + getHostName(location) { + if (!location.host) { + return location.href; + } + return location.protocol + "//" + location.host; + }, + + populateStoresForHost(host, window) { + try { + this.hostVsStores.set(host, window[type]); + } catch (ex) { + console.warn(`Failed to enumerate ${type} for host ${host}: ${ex}`); + } + }, + + populateStoresForHosts() { + this.hostVsStores = new Map(); + for (let window of this.windows) { + this.populateStoresForHost(this.getHostName(window.location), window); + } + }, + + getFields: Task.async(function* () { + return [ + { name: "name", editable: 1}, + { name: "value", editable: 1} + ]; + }), + + /** + * Edit localStorage or sessionStorage fields. + * + * @param {Object} data + * See editCookie() for format details. + */ + editItem: Task.async(function* ({host, field, oldValue, items}) { + let storage = this.hostVsStores.get(host); + if (!storage) { + return; + } + + if (field === "name") { + storage.removeItem(oldValue); + } + + storage.setItem(items.name, items.value); + }), + + removeItem: Task.async(function* (host, name) { + let storage = this.hostVsStores.get(host); + if (!storage) { + return; + } + storage.removeItem(name); + }), + + removeAll: Task.async(function* (host) { + let storage = this.hostVsStores.get(host); + if (!storage) { + return; + } + storage.clear(); + }), + + observe(subject, topic, data) { + if (topic != "dom-storage2-changed" || data != type) { + return null; + } + + let host = this.getSchemaAndHost(subject.url); + + if (!this.hostVsStores.has(host)) { + return null; + } + + let action = "changed"; + if (subject.key == null) { + return this.storageActor.update("cleared", type, [host]); + } else if (subject.oldValue == null) { + action = "added"; + } else if (subject.newValue == null) { + action = "deleted"; + } + let updateData = {}; + updateData[host] = [subject.key]; + return this.storageActor.update(action, type, updateData); + }, + + /** + * Given a url, correctly determine its protocol + hostname part. + */ + getSchemaAndHost(url) { + let uri = Services.io.newURI(url, null, null); + if (!uri.host) { + return uri.spec; + } + return uri.scheme + "://" + uri.hostPort; + }, + + toStoreObject(item) { + if (!item) { + return null; + } + + return { + name: item.name, + value: new LongStringActor(this.conn, item.value || "") + }; + }, + }; +} + +/** + * The Local Storage actor and front. + */ +StorageActors.createActor({ + typeName: "localStorage", + observationTopic: "dom-storage2-changed" +}, getObjectForLocalOrSessionStorage("localStorage")); + +/** + * The Session Storage actor and front. + */ +StorageActors.createActor({ + typeName: "sessionStorage", + observationTopic: "dom-storage2-changed" +}, getObjectForLocalOrSessionStorage("sessionStorage")); + +StorageActors.createActor({ + typeName: "Cache" +}, { + getCachesForHost: Task.async(function* (host) { + let uri = Services.io.newURI(host, null, null); + let principal = + Services.scriptSecurityManager.getNoAppCodebasePrincipal(uri); + + // The first argument tells if you want to get |content| cache or |chrome| + // cache. + // The |content| cache is the cache explicitely named by the web content + // (service worker or web page). + // The |chrome| cache is the cache implicitely cached by the platform, + // hosting the source file of the service worker. + let { CacheStorage } = this.storageActor.window; + let cache = new CacheStorage("content", principal); + return cache; + }), + + preListStores: Task.async(function* () { + for (let host of this.hosts) { + yield this.populateStoresForHost(host); + } + }), + + form(form, detail) { + if (detail === "actorid") { + return this.actorID; + } + + let hosts = {}; + for (let host of this.hosts) { + hosts[host] = this.getNamesForHost(host); + } + + return { + actor: this.actorID, + hosts: hosts + }; + }, + + getNamesForHost(host) { + // UI code expect each name to be a JSON string of an array :/ + return [...this.hostVsStores.get(host).keys()].map(a => { + return JSON.stringify([a]); + }); + }, + + getValuesForHost: Task.async(function* (host, name) { + if (!name) { + return []; + } + // UI is weird and expect a JSON stringified array... and pass it back :/ + name = JSON.parse(name)[0]; + + let cache = this.hostVsStores.get(host).get(name); + let requests = yield cache.keys(); + let results = []; + for (let request of requests) { + let response = yield cache.match(request); + // Unwrap the response to get access to all its properties if the + // response happen to be 'opaque', when it is a Cross Origin Request. + response = response.cloneUnfiltered(); + results.push(yield this.processEntry(request, response)); + } + return results; + }), + + processEntry: Task.async(function* (request, response) { + return { + url: String(request.url), + status: String(response.statusText), + }; + }), + + getFields: Task.async(function* () { + return [ + { name: "url", editable: 0 }, + { name: "status", editable: 0 } + ]; + }), + + getHostName(location) { + if (!location.host) { + return location.href; + } + return location.protocol + "//" + location.host; + }, + + populateStoresForHost: Task.async(function* (host) { + let storeMap = new Map(); + let caches = yield this.getCachesForHost(host); + try { + for (let name of (yield caches.keys())) { + storeMap.set(name, (yield caches.open(name))); + } + } catch (ex) { + console.warn(`Failed to enumerate CacheStorage for host ${host}: ${ex}`); + } + this.hostVsStores.set(host, storeMap); + }), + + /** + * This method is overriden and left blank as for Cache Storage, this + * operation cannot be performed synchronously. Thus, the preListStores + * method exists to do the same task asynchronously. + */ + populateStoresForHosts() { + this.hostVsStores = new Map(); + }, + + /** + * Given a url, correctly determine its protocol + hostname part. + */ + getSchemaAndHost(url) { + let uri = Services.io.newURI(url, null, null); + return uri.scheme + "://" + uri.hostPort; + }, + + toStoreObject(item) { + return item; + }, + + removeItem: Task.async(function* (host, name) { + const cacheMap = this.hostVsStores.get(host); + if (!cacheMap) { + return; + } + + const parsedName = JSON.parse(name); + + if (parsedName.length == 1) { + // Delete the whole Cache object + const [ cacheName ] = parsedName; + cacheMap.delete(cacheName); + const cacheStorage = yield this.getCachesForHost(host); + yield cacheStorage.delete(cacheName); + this.onItemUpdated("deleted", host, [ cacheName ]); + } else if (parsedName.length == 2) { + // Delete one cached request + const [ cacheName, url ] = parsedName; + const cache = cacheMap.get(cacheName); + if (cache) { + yield cache.delete(url); + this.onItemUpdated("deleted", host, [ cacheName, url ]); + } + } + }), + + removeAll: Task.async(function* (host, name) { + const cacheMap = this.hostVsStores.get(host); + if (!cacheMap) { + return; + } + + const parsedName = JSON.parse(name); + + // Only a Cache object is a valid object to clear + if (parsedName.length == 1) { + const [ cacheName ] = parsedName; + const cache = cacheMap.get(cacheName); + if (cache) { + let keys = yield cache.keys(); + yield promise.all(keys.map(key => cache.delete(key))); + this.onItemUpdated("cleared", host, [ cacheName ]); + } + } + }), + + /** + * CacheStorage API doesn't support any notifications, we must fake them + */ + onItemUpdated(action, host, path) { + this.storageActor.update(action, "Cache", { + [host]: [ JSON.stringify(path) ] + }); + }, +}); + +/** + * Code related to the Indexed DB actor and front + */ + +// Metadata holder objects for various components of Indexed DB + +/** + * Meta data object for a particular index in an object store + * + * @param {IDBIndex} index + * The particular index from the object store. + */ +function IndexMetadata(index) { + this._name = index.name; + this._keyPath = index.keyPath; + this._unique = index.unique; + this._multiEntry = index.multiEntry; +} +IndexMetadata.prototype = { + toObject() { + return { + name: this._name, + keyPath: this._keyPath, + unique: this._unique, + multiEntry: this._multiEntry + }; + } +}; + +/** + * Meta data object for a particular object store in a db + * + * @param {IDBObjectStore} objectStore + * The particular object store from the db. + */ +function ObjectStoreMetadata(objectStore) { + this._name = objectStore.name; + this._keyPath = objectStore.keyPath; + this._autoIncrement = objectStore.autoIncrement; + this._indexes = []; + + for (let i = 0; i < objectStore.indexNames.length; i++) { + let index = objectStore.index(objectStore.indexNames[i]); + + let newIndex = { + keypath: index.keyPath, + multiEntry: index.multiEntry, + name: index.name, + objectStore: { + autoIncrement: index.objectStore.autoIncrement, + indexNames: [...index.objectStore.indexNames], + keyPath: index.objectStore.keyPath, + name: index.objectStore.name, + } + }; + + this._indexes.push([newIndex, new IndexMetadata(index)]); + } +} +ObjectStoreMetadata.prototype = { + toObject() { + return { + name: this._name, + keyPath: this._keyPath, + autoIncrement: this._autoIncrement, + indexes: JSON.stringify( + [...this._indexes.values()].map(index => index.toObject()) + ) + }; + } +}; + +/** + * Meta data object for a particular indexed db in a host. + * + * @param {string} origin + * The host associated with this indexed db. + * @param {IDBDatabase} db + * The particular indexed db. + */ +function DatabaseMetadata(origin, db) { + this._origin = origin; + this._name = db.name; + this._version = db.version; + this._objectStores = []; + + if (db.objectStoreNames.length) { + let transaction = db.transaction(db.objectStoreNames, "readonly"); + + for (let i = 0; i < transaction.objectStoreNames.length; i++) { + let objectStore = + transaction.objectStore(transaction.objectStoreNames[i]); + this._objectStores.push([transaction.objectStoreNames[i], + new ObjectStoreMetadata(objectStore)]); + } + } +} +DatabaseMetadata.prototype = { + get objectStores() { + return this._objectStores; + }, + + toObject() { + return { + name: this._name, + origin: this._origin, + version: this._version, + objectStores: this._objectStores.size + }; + } +}; + +StorageActors.createActor({ + typeName: "indexedDB" +}, { + initialize(storageActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.storageActor = storageActor; + + this.maybeSetupChildProcess(); + + this.objectsSize = {}; + this.storageActor = storageActor; + this.onWindowReady = this.onWindowReady.bind(this); + this.onWindowDestroyed = this.onWindowDestroyed.bind(this); + + events.on(this.storageActor, "window-ready", this.onWindowReady); + events.on(this.storageActor, "window-destroyed", this.onWindowDestroyed); + }, + + destroy() { + this.hostVsStores.clear(); + this.objectsSize = null; + + events.off(this.storageActor, "window-ready", this.onWindowReady); + events.off(this.storageActor, "window-destroyed", this.onWindowDestroyed); + }, + + /** + * Remove an indexedDB database from given host with a given name. + */ + removeDatabase: Task.async(function* (host, name) { + let win = this.storageActor.getWindowFromHost(host); + if (!win) { + return { error: `Window for host ${host} not found` }; + } + + let principal = win.document.nodePrincipal; + return this.removeDB(host, principal, name); + }), + + removeAll: Task.async(function* (host, name) { + let [db, store] = JSON.parse(name); + + let win = this.storageActor.getWindowFromHost(host); + if (!win) { + return; + } + + let principal = win.document.nodePrincipal; + this.clearDBStore(host, principal, db, store); + }), + + removeItem: Task.async(function* (host, name) { + let [db, store, id] = JSON.parse(name); + + let win = this.storageActor.getWindowFromHost(host); + if (!win) { + return; + } + + let principal = win.document.nodePrincipal; + this.removeDBRecord(host, principal, db, store, id); + }), + + getHostName(location) { + if (!location.host) { + return location.href; + } + return location.protocol + "//" + location.host; + }, + + /** + * This method is overriden and left blank as for indexedDB, this operation + * cannot be performed synchronously. Thus, the preListStores method exists to + * do the same task asynchronously. + */ + populateStoresForHosts() {}, + + getNamesForHost(host) { + let names = []; + + for (let [dbName, {objectStores}] of this.hostVsStores.get(host)) { + if (objectStores.size) { + for (let objectStore of objectStores.keys()) { + names.push(JSON.stringify([dbName, objectStore])); + } + } else { + names.push(JSON.stringify([dbName])); + } + } + return names; + }, + + /** + * Returns the total number of entries for various types of requests to + * getStoreObjects for Indexed DB actor. + * + * @param {string} host + * The host for the request. + * @param {array:string} names + * Array of stringified name objects for indexed db actor. + * The request type depends on the length of any parsed entry from this + * array. 0 length refers to request for the whole host. 1 length + * refers to request for a particular db in the host. 2 length refers + * to a particular object store in a db in a host. 3 length refers to + * particular items of an object store in a db in a host. + * @param {object} options + * An options object containing following properties: + * - index {string} The IDBIndex for the object store in the db. + */ + getObjectsSize(host, names, options) { + // In Indexed DB, we are interested in only the first name, as the pattern + // should follow in all entries. + let name = names[0]; + let parsedName = JSON.parse(name); + + if (parsedName.length == 3) { + // This is the case where specific entries from an object store were + // requested + return names.length; + } else if (parsedName.length == 2) { + // This is the case where all entries from an object store are requested. + let index = options.index; + let [db, objectStore] = parsedName; + if (this.objectsSize[host + db + objectStore + index]) { + return this.objectsSize[host + db + objectStore + index]; + } + } else if (parsedName.length == 1) { + // This is the case where details of all object stores in a db are + // requested. + if (this.hostVsStores.has(host) && + this.hostVsStores.get(host).has(parsedName[0])) { + return this.hostVsStores.get(host).get(parsedName[0]).objectStores.size; + } + } else if (!parsedName || !parsedName.length) { + // This is the case were details of all dbs in a host are requested. + if (this.hostVsStores.has(host)) { + return this.hostVsStores.get(host).size; + } + } + return 0; + }, + + /** + * Purpose of this method is same as populateStoresForHosts but this is async. + * This exact same operation cannot be performed in populateStoresForHosts + * method, as that method is called in initialize method of the actor, which + * cannot be asynchronous. + */ + preListStores: Task.async(function* () { + this.hostVsStores = new Map(); + + for (let host of this.hosts) { + yield this.populateStoresForHost(host); + } + }), + + populateStoresForHost: Task.async(function* (host) { + let storeMap = new Map(); + let {names} = yield this.getDBNamesForHost(host); + let win = this.storageActor.getWindowFromHost(host); + if (win) { + let principal = win.document.nodePrincipal; + + for (let name of names) { + let metadata = yield this.getDBMetaData(host, principal, name); + + metadata = indexedDBHelpers.patchMetadataMapsAndProtos(metadata); + storeMap.set(name, metadata); + } + } + + this.hostVsStores.set(host, storeMap); + }), + + /** + * Returns the over-the-wire implementation of the indexed db entity. + */ + toStoreObject(item) { + if (!item) { + return null; + } + + if ("indexes" in item) { + // Object store meta data + return { + objectStore: item.name, + keyPath: item.keyPath, + autoIncrement: item.autoIncrement, + indexes: item.indexes + }; + } + if ("objectStores" in item) { + // DB meta data + return { + db: item.name, + origin: item.origin, + version: item.version, + objectStores: item.objectStores + }; + } + // Indexed db entry + return { + name: item.name, + value: new LongStringActor(this.conn, JSON.stringify(item.value)) + }; + }, + + form(form, detail) { + if (detail === "actorid") { + return this.actorID; + } + + let hosts = {}; + for (let host of this.hosts) { + hosts[host] = this.getNamesForHost(host); + } + + return { + actor: this.actorID, + hosts: hosts + }; + }, + + onItemUpdated(action, host, path) { + // Database was removed, remove it from stores map + if (action === "deleted" && path.length === 1) { + if (this.hostVsStores.has(host)) { + this.hostVsStores.get(host).delete(path[0]); + } + } + + this.storageActor.update(action, "indexedDB", { + [host]: [ JSON.stringify(path) ] + }); + }, + + maybeSetupChildProcess() { + if (!DebuggerServer.isInChildProcess) { + this.backToChild = (func, rv) => rv; + this.getDBMetaData = indexedDBHelpers.getDBMetaData; + this.openWithPrincipal = indexedDBHelpers.openWithPrincipal; + this.getDBNamesForHost = indexedDBHelpers.getDBNamesForHost; + this.getSanitizedHost = indexedDBHelpers.getSanitizedHost; + this.getNameFromDatabaseFile = indexedDBHelpers.getNameFromDatabaseFile; + this.getValuesForHost = indexedDBHelpers.getValuesForHost; + this.getObjectStoreData = indexedDBHelpers.getObjectStoreData; + this.removeDB = indexedDBHelpers.removeDB; + this.removeDBRecord = indexedDBHelpers.removeDBRecord; + this.clearDBStore = indexedDBHelpers.clearDBStore; + return; + } + + const { sendAsyncMessage, addMessageListener } = + this.conn.parentMessageManager; + + this.conn.setupInParent({ + module: "devtools/server/actors/storage", + setupParent: "setupParentProcessForIndexedDB" + }); + + this.getDBMetaData = callParentProcessAsync.bind(null, "getDBMetaData"); + this.getDBNamesForHost = callParentProcessAsync.bind(null, "getDBNamesForHost"); + this.getValuesForHost = callParentProcessAsync.bind(null, "getValuesForHost"); + this.removeDB = callParentProcessAsync.bind(null, "removeDB"); + this.removeDBRecord = callParentProcessAsync.bind(null, "removeDBRecord"); + this.clearDBStore = callParentProcessAsync.bind(null, "clearDBStore"); + + addMessageListener("debug:storage-indexedDB-request-child", msg => { + switch (msg.json.method) { + case "backToChild": { + let [func, rv] = msg.json.args; + let deferred = unresolvedPromises.get(func); + if (deferred) { + unresolvedPromises.delete(func); + deferred.resolve(rv); + } + break; + } + case "onItemUpdated": { + let [action, host, path] = msg.json.args; + this.onItemUpdated(action, host, path); + } + } + }); + + let unresolvedPromises = new Map(); + function callParentProcessAsync(methodName, ...args) { + let deferred = promise.defer(); + + unresolvedPromises.set(methodName, deferred); + + sendAsyncMessage("debug:storage-indexedDB-request-parent", { + method: methodName, + args: args + }); + + return deferred.promise; + } + }, + + getFields: Task.async(function* (subType) { + switch (subType) { + // Detail of database + case "database": + return [ + { name: "objectStore", editable: 0 }, + { name: "keyPath", editable: 0 }, + { name: "autoIncrement", editable: 0 }, + { name: "indexes", editable: 0 }, + ]; + + // Detail of object store + case "object store": + return [ + { name: "name", editable: 0 }, + { name: "value", editable: 0 } + ]; + + // Detail of indexedDB for one origin + default: + return [ + { name: "db", editable: 0 }, + { name: "origin", editable: 0 }, + { name: "version", editable: 0 }, + { name: "objectStores", editable: 0 }, + ]; + } + }) +}); + +var indexedDBHelpers = { + backToChild(...args) { + let mm = Cc["@mozilla.org/globalmessagemanager;1"] + .getService(Ci.nsIMessageListenerManager); + + mm.broadcastAsyncMessage("debug:storage-indexedDB-request-child", { + method: "backToChild", + args: args + }); + }, + + onItemUpdated(action, host, path) { + let mm = Cc["@mozilla.org/globalmessagemanager;1"] + .getService(Ci.nsIMessageListenerManager); + + mm.broadcastAsyncMessage("debug:storage-indexedDB-request-child", { + method: "onItemUpdated", + args: [ action, host, path ] + }); + }, + + /** + * Fetches and stores all the metadata information for the given database + * `name` for the given `host` with its `principal`. The stored metadata + * information is of `DatabaseMetadata` type. + */ + getDBMetaData: Task.async(function* (host, principal, name) { + let request = this.openWithPrincipal(principal, name); + let success = promise.defer(); + + request.onsuccess = event => { + let db = event.target.result; + + let dbData = new DatabaseMetadata(host, db); + db.close(); + + success.resolve(this.backToChild("getDBMetaData", dbData)); + }; + request.onerror = ({target}) => { + console.error( + `Error opening indexeddb database ${name} for host ${host}`, target.error); + success.resolve(this.backToChild("getDBMetaData", null)); + }; + return success.promise; + }), + + /** + * Opens an indexed db connection for the given `principal` and + * database `name`. + */ + openWithPrincipal(principal, name) { + return indexedDBForStorage.openForPrincipal(principal, name); + }, + + removeDB: Task.async(function* (host, principal, name) { + let result = new promise(resolve => { + let request = indexedDBForStorage.deleteForPrincipal(principal, name); + + request.onsuccess = () => { + resolve({}); + this.onItemUpdated("deleted", host, [name]); + }; + + request.onblocked = () => { + console.warn(`Deleting indexedDB database ${name} for host ${host} is blocked`); + resolve({ blocked: true }); + }; + + request.onerror = () => { + let { error } = request; + console.warn( + `Error deleting indexedDB database ${name} for host ${host}: ${error}`); + resolve({ error: error.message }); + }; + + // If the database is blocked repeatedly, the onblocked event will not + // be fired again. To avoid waiting forever, report as blocked if nothing + // else happens after 3 seconds. + setTimeout(() => resolve({ blocked: true }), 3000); + }); + + return this.backToChild("removeDB", yield result); + }), + + removeDBRecord: Task.async(function* (host, principal, dbName, storeName, id) { + let db; + + try { + db = yield new promise((resolve, reject) => { + let request = this.openWithPrincipal(principal, dbName); + request.onsuccess = ev => resolve(ev.target.result); + request.onerror = ev => reject(ev.target.error); + }); + + let transaction = db.transaction(storeName, "readwrite"); + let store = transaction.objectStore(storeName); + + yield new promise((resolve, reject) => { + let request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = ev => reject(ev.target.error); + }); + + this.onItemUpdated("deleted", host, [dbName, storeName, id]); + } catch (error) { + let recordPath = [dbName, storeName, id].join("/"); + console.error(`Failed to delete indexedDB record: ${recordPath}: ${error}`); + } + + if (db) { + db.close(); + } + + return this.backToChild("removeDBRecord", null); + }), + + clearDBStore: Task.async(function* (host, principal, dbName, storeName) { + let db; + + try { + db = yield new promise((resolve, reject) => { + let request = this.openWithPrincipal(principal, dbName); + request.onsuccess = ev => resolve(ev.target.result); + request.onerror = ev => reject(ev.target.error); + }); + + let transaction = db.transaction(storeName, "readwrite"); + let store = transaction.objectStore(storeName); + + yield new promise((resolve, reject) => { + let request = store.clear(); + request.onsuccess = () => resolve(); + request.onerror = ev => reject(ev.target.error); + }); + + this.onItemUpdated("cleared", host, [dbName, storeName]); + } catch (error) { + let storePath = [dbName, storeName].join("/"); + console.error(`Failed to clear indexedDB store: ${storePath}: ${error}`); + } + + if (db) { + db.close(); + } + + return this.backToChild("clearDBStore", null); + }), + + /** + * Fetches all the databases and their metadata for the given `host`. + */ + getDBNamesForHost: Task.async(function* (host) { + let sanitizedHost = this.getSanitizedHost(host); + let directory = OS.Path.join(OS.Constants.Path.profileDir, "storage", + "default", sanitizedHost, "idb"); + + let exists = yield OS.File.exists(directory); + if (!exists && host.startsWith("about:")) { + // try for moz-safe-about directory + sanitizedHost = this.getSanitizedHost("moz-safe-" + host); + directory = OS.Path.join(OS.Constants.Path.profileDir, "storage", + "permanent", sanitizedHost, "idb"); + exists = yield OS.File.exists(directory); + } + if (!exists) { + return this.backToChild("getDBNamesForHost", {names: []}); + } + + let names = []; + let dirIterator = new OS.File.DirectoryIterator(directory); + try { + yield dirIterator.forEach(file => { + // Skip directories. + if (file.isDir) { + return null; + } + + // Skip any non-sqlite files. + if (!file.name.endsWith(".sqlite")) { + return null; + } + + return this.getNameFromDatabaseFile(file.path).then(name => { + if (name) { + names.push(name); + } + return null; + }); + }); + } finally { + dirIterator.close(); + } + return this.backToChild("getDBNamesForHost", {names: names}); + }), + + /** + * Removes any illegal characters from the host name to make it a valid file + * name. + */ + getSanitizedHost(host) { + return host.replace(ILLEGAL_CHAR_REGEX, "+"); + }, + + /** + * Retrieves the proper indexed db database name from the provided .sqlite + * file location. + */ + getNameFromDatabaseFile: Task.async(function* (path) { + let connection = null; + let retryCount = 0; + + // Content pages might be having an open transaction for the same indexed db + // which this sqlite file belongs to. In that case, sqlite.openConnection + // will throw. Thus we retey for some time to see if lock is removed. + while (!connection && retryCount++ < 25) { + try { + connection = yield Sqlite.openConnection({ path: path }); + } catch (ex) { + // Continuously retrying is overkill. Waiting for 100ms before next try + yield sleep(100); + } + } + + if (!connection) { + return null; + } + + let rows = yield connection.execute("SELECT name FROM database"); + if (rows.length != 1) { + return null; + } + + let name = rows[0].getResultByName("name"); + + yield connection.close(); + + return name; + }), + + getValuesForHost: Task.async(function* (host, name = "null", options, + hostVsStores, principal) { + name = JSON.parse(name); + if (!name || !name.length) { + // This means that details about the db in this particular host are + // requested. + let dbs = []; + if (hostVsStores.has(host)) { + for (let [, db] of hostVsStores.get(host)) { + db = indexedDBHelpers.patchMetadataMapsAndProtos(db); + dbs.push(db.toObject()); + } + } + return this.backToChild("getValuesForHost", {dbs: dbs}); + } + + let [db2, objectStore, id] = name; + if (!objectStore) { + // This means that details about all the object stores in this db are + // requested. + let objectStores = []; + if (hostVsStores.has(host) && hostVsStores.get(host).has(db2)) { + let db = hostVsStores.get(host).get(db2); + + db = indexedDBHelpers.patchMetadataMapsAndProtos(db); + + let objectStores2 = db.objectStores; + + for (let objectStore2 of objectStores2) { + objectStores.push(objectStore2[1].toObject()); + } + } + return this.backToChild("getValuesForHost", {objectStores: objectStores}); + } + // Get either all entries from the object store, or a particular id + let result = yield this.getObjectStoreData(host, principal, db2, + objectStore, id, options.index, options.size); + return this.backToChild("getValuesForHost", {result: result}); + }), + + /** + * Returns all or requested entries from a particular objectStore from the db + * in the given host. + * + * @param {string} host + * The given host. + * @param {nsIPrincipal} principal + * The principal of the given document. + * @param {string} dbName + * The name of the indexed db from the above host. + * @param {string} objectStore + * The name of the object store from the above db. + * @param {string} id + * id of the requested entry from the above object store. + * null if all entries from the above object store are requested. + * @param {string} index + * name of the IDBIndex to be iterated on while fetching entries. + * null or "name" if no index is to be iterated. + * @param {number} offset + * ofsset of the entries to be fetched. + * @param {number} size + * The intended size of the entries to be fetched. + */ + getObjectStoreData(host, principal, dbName, objectStore, id, index, + offset, size) { + let request = this.openWithPrincipal(principal, dbName); + let success = promise.defer(); + let data = []; + let db; + + if (!size || size > MAX_STORE_OBJECT_COUNT) { + size = MAX_STORE_OBJECT_COUNT; + } + + request.onsuccess = event => { + db = event.target.result; + + let transaction = db.transaction(objectStore, "readonly"); + let source = transaction.objectStore(objectStore); + if (index && index != "name") { + source = source.index(index); + } + + source.count().onsuccess = event2 => { + let objectsSize = []; + let count = event2.target.result; + objectsSize.push({ + key: host + dbName + objectStore + index, + count: count + }); + + if (!offset) { + offset = 0; + } else if (offset > count) { + db.close(); + success.resolve([]); + return; + } + + if (id) { + source.get(id).onsuccess = event3 => { + db.close(); + success.resolve([{name: id, value: event3.target.result}]); + }; + } else { + source.openCursor().onsuccess = event4 => { + let cursor = event4.target.result; + + if (!cursor || data.length >= size) { + db.close(); + success.resolve({ + data: data, + objectsSize: objectsSize + }); + return; + } + if (offset-- <= 0) { + data.push({name: cursor.key, value: cursor.value}); + } + cursor.continue(); + }; + } + }; + }; + request.onerror = () => { + db.close(); + success.resolve([]); + }; + return success.promise; + }, + + /** + * When indexedDB metadata is parsed to and from JSON then the object's + * prototype is dropped and any Maps are changed to arrays of arrays. This + * method is used to repair the prototypes and fix any broken Maps. + */ + patchMetadataMapsAndProtos(metadata) { + let md = Object.create(DatabaseMetadata.prototype); + Object.assign(md, metadata); + + md._objectStores = new Map(metadata._objectStores); + + for (let [name, store] of md._objectStores) { + let obj = Object.create(ObjectStoreMetadata.prototype); + Object.assign(obj, store); + + md._objectStores.set(name, obj); + + if (typeof store._indexes.length !== "undefined") { + obj._indexes = new Map(store._indexes); + } + + for (let [name2, value] of obj._indexes) { + let obj2 = Object.create(IndexMetadata.prototype); + Object.assign(obj2, value); + + obj._indexes.set(name2, obj2); + } + } + + return md; + }, + + handleChildRequest(msg) { + let args = msg.data.args; + + switch (msg.json.method) { + case "getDBMetaData": { + let [host, principal, name] = args; + return indexedDBHelpers.getDBMetaData(host, principal, name); + } + case "getDBNamesForHost": { + let [host] = args; + return indexedDBHelpers.getDBNamesForHost(host); + } + case "getValuesForHost": { + let [host, name, options, hostVsStores, principal] = args; + return indexedDBHelpers.getValuesForHost(host, name, options, + hostVsStores, principal); + } + case "removeDB": { + let [host, principal, name] = args; + return indexedDBHelpers.removeDB(host, principal, name); + } + case "removeDBRecord": { + let [host, principal, db, store, id] = args; + return indexedDBHelpers.removeDBRecord(host, principal, db, store, id); + } + case "clearDBStore": { + let [host, principal, db, store] = args; + return indexedDBHelpers.clearDBStore(host, principal, db, store); + } + default: + console.error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD", msg.json.method); + throw new Error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD"); + } + } +}; + +/** + * E10S parent/child setup helpers + */ + +exports.setupParentProcessForIndexedDB = function ({ mm, prefix }) { + // listen for director-script requests from the child process + setMessageManager(mm); + + function setMessageManager(newMM) { + if (mm) { + mm.removeMessageListener("debug:storage-indexedDB-request-parent", + indexedDBHelpers.handleChildRequest); + } + mm = newMM; + if (mm) { + mm.addMessageListener("debug:storage-indexedDB-request-parent", + indexedDBHelpers.handleChildRequest); + } + } + + return { + onBrowserSwap: setMessageManager, + onDisconnected: () => setMessageManager(null), + }; +}; + +/** + * The main Storage Actor. + */ +let StorageActor = protocol.ActorClassWithSpec(specs.storageSpec, { + typeName: "storage", + + get window() { + return this.parentActor.window; + }, + + get document() { + return this.parentActor.window.document; + }, + + get windows() { + return this.childWindowPool; + }, + + initialize(conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.conn = conn; + this.parentActor = tabActor; + + this.childActorPool = new Map(); + this.childWindowPool = new Set(); + + // Fetch all the inner iframe windows in this tab. + this.fetchChildWindows(this.parentActor.docShell); + + // Initialize the registered store types + for (let [store, ActorConstructor] of storageTypePool) { + this.childActorPool.set(store, new ActorConstructor(this)); + } + + // Notifications that help us keep track of newly added windows and windows + // that got removed + Services.obs.addObserver(this, "content-document-global-created", false); + Services.obs.addObserver(this, "inner-window-destroyed", false); + this.onPageChange = this.onPageChange.bind(this); + + let handler = tabActor.chromeEventHandler; + handler.addEventListener("pageshow", this.onPageChange, true); + handler.addEventListener("pagehide", this.onPageChange, true); + + this.destroyed = false; + this.boundUpdate = {}; + }, + + destroy() { + clearTimeout(this.batchTimer); + this.batchTimer = null; + // Remove observers + Services.obs.removeObserver(this, "content-document-global-created", false); + Services.obs.removeObserver(this, "inner-window-destroyed", false); + this.destroyed = true; + if (this.parentActor.browser) { + this.parentActor.browser.removeEventListener( + "pageshow", this.onPageChange, true); + this.parentActor.browser.removeEventListener( + "pagehide", this.onPageChange, true); + } + // Destroy the registered store types + for (let actor of this.childActorPool.values()) { + actor.destroy(); + } + this.childActorPool.clear(); + this.childWindowPool.clear(); + this.childWindowPool = this.childActorPool = this.__poolMap = this.conn = + this.parentActor = this.boundUpdate = this.registeredPool = + this._pendingResponse = null; + }, + + /** + * Given a docshell, recursively find out all the child windows from it. + * + * @param {nsIDocShell} item + * The docshell from which all inner windows need to be extracted. + */ + fetchChildWindows(item) { + let docShell = item.QueryInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIDocShellTreeItem); + if (!docShell.contentViewer) { + return null; + } + let window = docShell.contentViewer.DOMDocument.defaultView; + if (window.location.href == "about:blank") { + // Skip out about:blank windows as Gecko creates them multiple times while + // creating any global. + return null; + } + this.childWindowPool.add(window); + for (let i = 0; i < docShell.childCount; i++) { + let child = docShell.getChildAt(i); + this.fetchChildWindows(child); + } + return null; + }, + + isIncludedInTopLevelWindow(window) { + return isWindowIncluded(this.window, window); + }, + + getWindowFromInnerWindowID(innerID) { + innerID = innerID.QueryInterface(Ci.nsISupportsPRUint64).data; + for (let win of this.childWindowPool.values()) { + let id = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .currentInnerWindowID; + if (id == innerID) { + return win; + } + } + return null; + }, + + getWindowFromHost(host) { + for (let win of this.childWindowPool.values()) { + let origin = win.document + .nodePrincipal + .originNoSuffix; + let url = win.document.URL; + if (origin === host || url === host) { + return win; + } + } + return null; + }, + + /** + * Event handler for any docshell update. This lets us figure out whenever + * any new window is added, or an existing window is removed. + */ + observe(subject, topic) { + if (subject.location && + (!subject.location.href || subject.location.href == "about:blank")) { + return null; + } + + if (topic == "content-document-global-created" && + this.isIncludedInTopLevelWindow(subject)) { + this.childWindowPool.add(subject); + events.emit(this, "window-ready", subject); + } else if (topic == "inner-window-destroyed") { + let window = this.getWindowFromInnerWindowID(subject); + if (window) { + this.childWindowPool.delete(window); + events.emit(this, "window-destroyed", window); + } + } + return null; + }, + + /** + * Called on "pageshow" or "pagehide" event on the chromeEventHandler of + * current tab. + * + * @param {event} The event object passed to the handler. We are using these + * three properties from the event: + * - target {document} The document corresponding to the event. + * - type {string} Name of the event - "pageshow" or "pagehide". + * - persisted {boolean} true if there was no + * "content-document-global-created" notification along + * this event. + */ + onPageChange({target, type, persisted}) { + if (this.destroyed) { + return; + } + + let window = target.defaultView; + + if (type == "pagehide" && this.childWindowPool.delete(window)) { + events.emit(this, "window-destroyed", window); + } else if (type == "pageshow" && persisted && window.location.href && + window.location.href != "about:blank" && + this.isIncludedInTopLevelWindow(window)) { + this.childWindowPool.add(window); + events.emit(this, "window-ready", window); + } + }, + + /** + * Lists the available hosts for all the registered storage types. + * + * @returns {object} An object containing with the following structure: + * - <storageType> : [{ + * actor: <actorId>, + * host: <hostname> + * }] + */ + listStores: Task.async(function* () { + let toReturn = {}; + + for (let [name, value] of this.childActorPool) { + if (value.preListStores) { + yield value.preListStores(); + } + toReturn[name] = value; + } + + return toReturn; + }), + + /** + * This method is called by the registered storage types so as to tell the + * Storage Actor that there are some changes in the stores. Storage Actor then + * notifies the client front about these changes at regular (BATCH_DELAY) + * interval. + * + * @param {string} action + * The type of change. One of "added", "changed" or "deleted" + * @param {string} storeType + * The storage actor in which this change has occurred. + * @param {object} data + * The update object. This object is of the following format: + * - { + * <host1>: [<store_names1>, <store_name2>...], + * <host2>: [<store_names34>...], + * } + * Where host1, host2 are the host in which this change happened and + * [<store_namesX] is an array of the names of the changed store objects. + * Pass an empty array if the host itself was affected: either completely + * removed or cleared. + */ + update(action, storeType, data) { + if (action == "cleared") { + events.emit(this, "stores-cleared", { [storeType]: data }); + return null; + } + + if (this.batchTimer) { + clearTimeout(this.batchTimer); + } + if (!this.boundUpdate[action]) { + this.boundUpdate[action] = {}; + } + if (!this.boundUpdate[action][storeType]) { + this.boundUpdate[action][storeType] = {}; + } + for (let host in data) { + if (!this.boundUpdate[action][storeType][host]) { + this.boundUpdate[action][storeType][host] = []; + } + for (let name of data[host]) { + if (!this.boundUpdate[action][storeType][host].includes(name)) { + this.boundUpdate[action][storeType][host].push(name); + } + } + } + if (action == "added") { + // If the same store name was previously deleted or changed, but now is + // added somehow, dont send the deleted or changed update. + this.removeNamesFromUpdateList("deleted", storeType, data); + this.removeNamesFromUpdateList("changed", storeType, data); + } else if (action == "changed" && this.boundUpdate.added && + this.boundUpdate.added[storeType]) { + // If something got added and changed at the same time, then remove those + // items from changed instead. + this.removeNamesFromUpdateList("changed", storeType, + this.boundUpdate.added[storeType]); + } else if (action == "deleted") { + // If any item got delete, or a host got delete, no point in sending + // added or changed update + this.removeNamesFromUpdateList("added", storeType, data); + this.removeNamesFromUpdateList("changed", storeType, data); + for (let host in data) { + if (data[host].length == 0 && this.boundUpdate.added && + this.boundUpdate.added[storeType] && + this.boundUpdate.added[storeType][host]) { + delete this.boundUpdate.added[storeType][host]; + } + if (data[host].length == 0 && this.boundUpdate.changed && + this.boundUpdate.changed[storeType] && + this.boundUpdate.changed[storeType][host]) { + delete this.boundUpdate.changed[storeType][host]; + } + } + } + + this.batchTimer = setTimeout(() => { + clearTimeout(this.batchTimer); + events.emit(this, "stores-update", this.boundUpdate); + this.boundUpdate = {}; + }, BATCH_DELAY); + + return null; + }, + + /** + * This method removes data from the this.boundUpdate object in the same + * manner like this.update() adds data to it. + * + * @param {string} action + * The type of change. One of "added", "changed" or "deleted" + * @param {string} storeType + * The storage actor for which you want to remove the updates data. + * @param {object} data + * The update object. This object is of the following format: + * - { + * <host1>: [<store_names1>, <store_name2>...], + * <host2>: [<store_names34>...], + * } + * Where host1, host2 are the hosts which you want to remove and + * [<store_namesX] is an array of the names of the store objects. + */ + removeNamesFromUpdateList(action, storeType, data) { + for (let host in data) { + if (this.boundUpdate[action] && this.boundUpdate[action][storeType] && + this.boundUpdate[action][storeType][host]) { + for (let name in data[host]) { + let index = this.boundUpdate[action][storeType][host].indexOf(name); + if (index > -1) { + this.boundUpdate[action][storeType][host].splice(index, 1); + } + } + if (!this.boundUpdate[action][storeType][host].length) { + delete this.boundUpdate[action][storeType][host]; + } + } + } + return null; + } +}); + +exports.StorageActor = StorageActor; diff --git a/devtools/server/actors/string.js b/devtools/server/actors/string.js new file mode 100644 index 000000000..d90a8b31c --- /dev/null +++ b/devtools/server/actors/string.js @@ -0,0 +1,43 @@ +/* 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 {DebuggerServer} = require("devtools/server/main"); + +var promise = require("promise"); + +var protocol = require("devtools/shared/protocol"); +const {longStringSpec} = require("devtools/shared/specs/string"); + +exports.LongStringActor = protocol.ActorClassWithSpec(longStringSpec, { + initialize: function (conn, str) { + protocol.Actor.prototype.initialize.call(this, conn); + this.str = str; + this.short = (this.str.length < DebuggerServer.LONG_STRING_LENGTH); + }, + + destroy: function () { + this.str = null; + protocol.Actor.prototype.destroy.call(this); + }, + + form: function () { + if (this.short) { + return this.str; + } + return { + type: "longString", + actor: this.actorID, + length: this.str.length, + initial: this.str.substring(0, DebuggerServer.LONG_STRING_INITIAL_LENGTH) + }; + }, + + substring: function (start, end) { + return promise.resolve(this.str.substring(start, end)); + }, + + release: function () { } +}); diff --git a/devtools/server/actors/styleeditor.js b/devtools/server/actors/styleeditor.js new file mode 100644 index 000000000..5793a2baf --- /dev/null +++ b/devtools/server/actors/styleeditor.js @@ -0,0 +1,528 @@ +/* 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 {Cc, Ci} = require("chrome"); +const Services = require("Services"); +const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); +const promise = require("promise"); +const events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const {Arg, method, RetVal} = protocol; +const {fetch} = require("devtools/shared/DevToolsUtils"); +const {oldStyleSheetSpec, styleEditorSpec} = require("devtools/shared/specs/styleeditor"); + +loader.lazyGetter(this, "CssLogic", () => require("devtools/shared/inspector/css-logic")); + +var TRANSITION_CLASS = "moz-styleeditor-transitioning"; +var TRANSITION_DURATION_MS = 500; +var TRANSITION_RULE = "\ +:root.moz-styleeditor-transitioning, :root.moz-styleeditor-transitioning * {\ +transition-duration: " + TRANSITION_DURATION_MS + "ms !important; \ +transition-delay: 0ms !important;\ +transition-timing-function: ease-out !important;\ +transition-property: all !important;\ +}"; + +var LOAD_ERROR = "error-load"; + +var OldStyleSheetActor = protocol.ActorClassWithSpec(oldStyleSheetSpec, { + toString: function() { + return "[OldStyleSheetActor " + this.actorID + "]"; + }, + + /** + * Window of target + */ + get window() { + return this._window || this.parentActor.window; + }, + + /** + * Document of target. + */ + get document() { + return this.window.document; + }, + + /** + * URL of underlying stylesheet. + */ + get href() { + return this.rawSheet.href; + }, + + /** + * Retrieve the index (order) of stylesheet in the document. + * + * @return number + */ + get styleSheetIndex() + { + if (this._styleSheetIndex == -1) { + for (let i = 0; i < this.document.styleSheets.length; i++) { + if (this.document.styleSheets[i] == this.rawSheet) { + this._styleSheetIndex = i; + break; + } + } + } + return this._styleSheetIndex; + }, + + initialize: function (aStyleSheet, aParentActor, aWindow) { + protocol.Actor.prototype.initialize.call(this, null); + + this.rawSheet = aStyleSheet; + this.parentActor = aParentActor; + this.conn = this.parentActor.conn; + + this._window = aWindow; + + // text and index are unknown until source load + this.text = null; + this._styleSheetIndex = -1; + + this._transitionRefCount = 0; + + // if this sheet has an @import, then it's rules are loaded async + let ownerNode = this.rawSheet.ownerNode; + if (ownerNode) { + let onSheetLoaded = (event) => { + ownerNode.removeEventListener("load", onSheetLoaded, false); + this._notifyPropertyChanged("ruleCount"); + }; + + ownerNode.addEventListener("load", onSheetLoaded, false); + } + }, + + /** + * Get the current state of the actor + * + * @return {object} + * With properties of the underlying stylesheet, plus 'text', + * 'styleSheetIndex' and 'parentActor' if it's @imported + */ + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let docHref; + if (this.rawSheet.ownerNode) { + if (this.rawSheet.ownerNode instanceof Ci.nsIDOMHTMLDocument) { + docHref = this.rawSheet.ownerNode.location.href; + } + if (this.rawSheet.ownerNode.ownerDocument) { + docHref = this.rawSheet.ownerNode.ownerDocument.location.href; + } + } + + let form = { + actor: this.actorID, // actorID is set when this actor is added to a pool + href: this.href, + nodeHref: docHref, + disabled: this.rawSheet.disabled, + title: this.rawSheet.title, + system: !CssLogic.isContentStylesheet(this.rawSheet), + styleSheetIndex: this.styleSheetIndex + }; + + try { + form.ruleCount = this.rawSheet.cssRules.length; + } + catch (e) { + // stylesheet had an @import rule that wasn't loaded yet + } + return form; + }, + + /** + * Toggle the disabled property of the style sheet + * + * @return {object} + * 'disabled' - the disabled state after toggling. + */ + toggleDisabled: function () { + this.rawSheet.disabled = !this.rawSheet.disabled; + this._notifyPropertyChanged("disabled"); + + return this.rawSheet.disabled; + }, + + /** + * Send an event notifying that a property of the stylesheet + * has changed. + * + * @param {string} property + * Name of the changed property + */ + _notifyPropertyChanged: function (property) { + events.emit(this, "property-change", property, this.form()[property]); + }, + + /** + * Fetch the source of the style sheet from its URL. Send a "sourceLoad" + * event when it's been fetched. + */ + fetchSource: function () { + this._getText().then((content) => { + events.emit(this, "source-load", this.text); + }); + }, + + /** + * Fetch the text for this stylesheet from the cache or network. Return + * cached text if it's already been fetched. + * + * @return {Promise} + * Promise that resolves with a string text of the stylesheet. + */ + _getText: function () { + if (this.text) { + return promise.resolve(this.text); + } + + if (!this.href) { + // this is an inline <style> sheet + let content = this.rawSheet.ownerNode.textContent; + this.text = content; + return promise.resolve(content); + } + + let options = { + policy: Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET, + window: this.window, + charset: this._getCSSCharset() + }; + + return fetch(this.href, options).then(({ content }) => { + this.text = content; + return content; + }); + }, + + /** + * Get the charset of the stylesheet according to the character set rules + * defined in <http://www.w3.org/TR/CSS2/syndata.html#charset>. + * Note that some of the algorithm is implemented in DevToolsUtils.fetch. + */ + _getCSSCharset: function () + { + let sheet = this.rawSheet; + if (sheet) { + // Do we have a @charset rule in the stylesheet? + // step 2 of syndata.html (without the BOM check). + if (sheet.cssRules) { + let rules = sheet.cssRules; + if (rules.length + && rules.item(0).type == Ci.nsIDOMCSSRule.CHARSET_RULE) { + return rules.item(0).encoding; + } + } + + // step 3: charset attribute of <link> or <style> element, if it exists + if (sheet.ownerNode && sheet.ownerNode.getAttribute) { + let linkCharset = sheet.ownerNode.getAttribute("charset"); + if (linkCharset != null) { + return linkCharset; + } + } + + // step 4 (1 of 2): charset of referring stylesheet. + let parentSheet = sheet.parentStyleSheet; + if (parentSheet && parentSheet.cssRules && + parentSheet.cssRules[0].type == Ci.nsIDOMCSSRule.CHARSET_RULE) { + return parentSheet.cssRules[0].encoding; + } + + // step 4 (2 of 2): charset of referring document. + if (sheet.ownerNode && sheet.ownerNode.ownerDocument.characterSet) { + return sheet.ownerNode.ownerDocument.characterSet; + } + } + + // step 5: default to utf-8. + return "UTF-8"; + }, + + /** + * Update the style sheet in place with new text. + * + * @param {object} request + * 'text' - new text + * 'transition' - whether to do CSS transition for change. + */ + update: function (text, transition) { + DOMUtils.parseStyleSheet(this.rawSheet, text); + + this.text = text; + + this._notifyPropertyChanged("ruleCount"); + + if (transition) { + this._insertTransistionRule(); + } + else { + this._notifyStyleApplied(); + } + }, + + /** + * Insert a catch-all transition rule into the document. Set a timeout + * to remove the rule after a certain time. + */ + _insertTransistionRule: function () { + // Insert the global transition rule + // Use a ref count to make sure we do not add it multiple times.. and remove + // it only when all pending StyleEditor-generated transitions ended. + if (this._transitionRefCount == 0) { + this.rawSheet.insertRule(TRANSITION_RULE, this.rawSheet.cssRules.length); + this.document.documentElement.classList.add(TRANSITION_CLASS); + } + + this._transitionRefCount++; + + // Set up clean up and commit after transition duration (+10% buffer) + // @see _onTransitionEnd + this.window.setTimeout(this._onTransitionEnd.bind(this), + Math.floor(TRANSITION_DURATION_MS * 1.1)); + }, + + /** + * This cleans up class and rule added for transition effect and then + * notifies that the style has been applied. + */ + _onTransitionEnd: function () + { + if (--this._transitionRefCount == 0) { + this.document.documentElement.classList.remove(TRANSITION_CLASS); + this.rawSheet.deleteRule(this.rawSheet.cssRules.length - 1); + } + + events.emit(this, "style-applied"); + } +}); + +exports.OldStyleSheetActor = OldStyleSheetActor; + +/** + * Creates a StyleEditorActor. StyleEditorActor provides remote access to the + * stylesheets of a document. + */ +var StyleEditorActor = exports.StyleEditorActor = protocol.ActorClassWithSpec(styleEditorSpec, { + /** + * The window we work with, taken from the parent actor. + */ + get window() { + return this.parentActor.window; + }, + + /** + * The current content document of the window we work with. + */ + get document() { + return this.window.document; + }, + + form: function () + { + return { actor: this.actorID }; + }, + + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.parentActor = tabActor; + + // keep a map of sheets-to-actors so we don't create two actors for one sheet + this._sheets = new Map(); + }, + + /** + * Destroy the current StyleEditorActor instance. + */ + destroy: function () + { + this._sheets.clear(); + }, + + /** + * Called by client when target navigates to a new document. + * Adds load listeners to document. + */ + newDocument: function () { + // delete previous document's actors + this._clearStyleSheetActors(); + + // Note: listening for load won't be necessary once + // https://bugzilla.mozilla.org/show_bug.cgi?id=839103 is fixed + if (this.document.readyState == "complete") { + this._onDocumentLoaded(); + } + else { + this.window.addEventListener("load", this._onDocumentLoaded, false); + } + return {}; + }, + + /** + * Event handler for document loaded event. Add actor for each stylesheet + * and send an event notifying of the load + */ + _onDocumentLoaded: function (event) { + if (event) { + this.window.removeEventListener("load", this._onDocumentLoaded, false); + } + + let documents = [this.document]; + var forms = []; + for (let doc of documents) { + let sheetForms = this._addStyleSheets(doc.styleSheets); + forms = forms.concat(sheetForms); + // Recursively handle style sheets of the documents in iframes. + for (let iframe of doc.getElementsByTagName("iframe")) { + documents.push(iframe.contentDocument); + } + } + + events.emit(this, "document-load", forms); + }, + + /** + * Add all the stylesheets to the map and create an actor for each one + * if not already created. Send event that there are new stylesheets. + * + * @param {[DOMStyleSheet]} styleSheets + * Stylesheets to add + * @return {[object]} + * Array of actors for each StyleSheetActor created + */ + _addStyleSheets: function (styleSheets) + { + let sheets = []; + for (let i = 0; i < styleSheets.length; i++) { + let styleSheet = styleSheets[i]; + sheets.push(styleSheet); + + // Get all sheets, including imported ones + let imports = this._getImported(styleSheet); + sheets = sheets.concat(imports); + } + let actors = sheets.map(this._createStyleSheetActor.bind(this)); + + return actors; + }, + + /** + * Create a new actor for a style sheet, if it hasn't already been created. + * + * @param {DOMStyleSheet} styleSheet + * The style sheet to create an actor for. + * @return {StyleSheetActor} + * The actor for this style sheet + */ + _createStyleSheetActor: function (styleSheet) + { + if (this._sheets.has(styleSheet)) { + return this._sheets.get(styleSheet); + } + let actor = new OldStyleSheetActor(styleSheet, this); + + this.manage(actor); + this._sheets.set(styleSheet, actor); + + return actor; + }, + + /** + * Get all the stylesheets @imported from a stylesheet. + * + * @param {DOMStyleSheet} styleSheet + * Style sheet to search + * @return {array} + * All the imported stylesheets + */ + _getImported: function (styleSheet) { + let imported = []; + + for (let i = 0; i < styleSheet.cssRules.length; i++) { + let rule = styleSheet.cssRules[i]; + if (rule.type == Ci.nsIDOMCSSRule.IMPORT_RULE) { + // Associated styleSheet may be null if it has already been seen due to + // duplicate @imports for the same URL. + if (!rule.styleSheet) { + continue; + } + imported.push(rule.styleSheet); + + // recurse imports in this stylesheet as well + imported = imported.concat(this._getImported(rule.styleSheet)); + } + else if (rule.type != Ci.nsIDOMCSSRule.CHARSET_RULE) { + // @import rules must precede all others except @charset + break; + } + } + return imported; + }, + + /** + * Clear all the current stylesheet actors in map. + */ + _clearStyleSheetActors: function () { + for (let actor in this._sheets) { + this.unmanage(this._sheets[actor]); + } + this._sheets.clear(); + }, + + /** + * Create a new style sheet in the document with the given text. + * Return an actor for it. + * + * @param {object} request + * Debugging protocol request object, with 'text property' + * @return {object} + * Object with 'styelSheet' property for form on new actor. + */ + newStyleSheet: function (text) { + let parent = this.document.documentElement; + let style = this.document.createElementNS("http://www.w3.org/1999/xhtml", "style"); + style.setAttribute("type", "text/css"); + + if (text) { + style.appendChild(this.document.createTextNode(text)); + } + parent.appendChild(style); + + let actor = this._createStyleSheetActor(style.sheet); + return actor; + } +}); + +XPCOMUtils.defineLazyGetter(this, "DOMUtils", function () { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); +}); + +exports.StyleEditorActor = StyleEditorActor; + +/** + * Normalize multiple relative paths towards the base paths on the right. + */ +function normalize(...aURLs) { + let base = Services.io.newURI(aURLs.pop(), null, null); + let url; + while ((url = aURLs.pop())) { + base = Services.io.newURI(url, null, base); + } + return base.spec; +} + +function dirname(aPath) { + return Services.io.newURI( + ".", null, Services.io.newURI(aPath, null, null)).spec; +} diff --git a/devtools/server/actors/styles.js b/devtools/server/actors/styles.js new file mode 100644 index 000000000..cdb812882 --- /dev/null +++ b/devtools/server/actors/styles.js @@ -0,0 +1,1687 @@ +/* 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 {Cc, Ci} = require("chrome"); +const promise = require("promise"); +const protocol = require("devtools/shared/protocol"); +const {LongStringActor} = require("devtools/server/actors/string"); +const {getDefinedGeometryProperties} = require("devtools/server/actors/highlighters/geometry-editor"); +const {parseDeclarations} = require("devtools/shared/css/parsing-utils"); +const {isCssPropertyKnown} = require("devtools/server/actors/css-properties"); +const {Task} = require("devtools/shared/task"); +const events = require("sdk/event/core"); + +// This will also add the "stylesheet" actor type for protocol.js to recognize +const {UPDATE_PRESERVING_RULES, UPDATE_GENERAL} = require("devtools/server/actors/stylesheets"); +const {pageStyleSpec, styleRuleSpec, ELEMENT_STYLE} = require("devtools/shared/specs/styles"); + +loader.lazyGetter(this, "CssLogic", () => require("devtools/server/css-logic").CssLogic); +loader.lazyGetter(this, "SharedCssLogic", () => require("devtools/shared/inspector/css-logic")); +loader.lazyGetter(this, "DOMUtils", () => Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils)); + +loader.lazyGetter(this, "PSEUDO_ELEMENTS", () => { + return DOMUtils.getCSSPseudoElementNames(); +}); + +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const FONT_PREVIEW_TEXT = "Abc"; +const FONT_PREVIEW_FONT_SIZE = 40; +const FONT_PREVIEW_FILLSTYLE = "black"; +const NORMAL_FONT_WEIGHT = 400; +const BOLD_FONT_WEIGHT = 700; +// Offset (in px) to avoid cutting off text edges of italic fonts. +const FONT_PREVIEW_OFFSET = 4; + +/** + * The PageStyle actor lets the client look at the styles on a page, as + * they are applied to a given node. + */ +var PageStyleActor = protocol.ActorClassWithSpec(pageStyleSpec, { + /** + * Create a PageStyleActor. + * + * @param inspector + * The InspectorActor that owns this PageStyleActor. + * + * @constructor + */ + initialize: function (inspector) { + protocol.Actor.prototype.initialize.call(this, null); + this.inspector = inspector; + if (!this.inspector.walker) { + throw Error("The inspector's WalkerActor must be created before " + + "creating a PageStyleActor."); + } + this.walker = inspector.walker; + this.cssLogic = new CssLogic(DOMUtils.isInheritedProperty); + + // Stores the association of DOM objects -> actors + this.refMap = new Map(); + + // Maps document elements to style elements, used to add new rules. + this.styleElements = new WeakMap(); + + this.onFrameUnload = this.onFrameUnload.bind(this); + this.onStyleSheetAdded = this.onStyleSheetAdded.bind(this); + + events.on(this.inspector.tabActor, "will-navigate", this.onFrameUnload); + events.on(this.inspector.tabActor, "stylesheet-added", this.onStyleSheetAdded); + + this._styleApplied = this._styleApplied.bind(this); + this._watchedSheets = new Set(); + }, + + destroy: function () { + if (!this.walker) { + return; + } + protocol.Actor.prototype.destroy.call(this); + events.off(this.inspector.tabActor, "will-navigate", this.onFrameUnload); + events.off(this.inspector.tabActor, "stylesheet-added", this.onStyleSheetAdded); + this.inspector = null; + this.walker = null; + this.refMap = null; + this.cssLogic = null; + this.styleElements = null; + + for (let sheet of this._watchedSheets) { + sheet.off("style-applied", this._styleApplied); + } + this._watchedSheets.clear(); + }, + + get conn() { + return this.inspector.conn; + }, + + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + return { + actor: this.actorID, + traits: { + // Whether the actor has had bug 1103993 fixed, which means that the + // getApplied method calls cssLogic.highlight(node) to recreate the + // style cache. Clients requesting getApplied from actors that have not + // been fixed must make sure cssLogic.highlight(node) was called before. + getAppliedCreatesStyleCache: true, + // Whether addNewRule accepts the editAuthored argument. + authoredStyles: true + } + }; + }, + + /** + * Called when a style sheet is updated. + */ + _styleApplied: function (kind, styleSheet) { + // No matter what kind of update is done, we need to invalidate + // the keyframe cache. + this.cssLogic.reset(); + if (kind === UPDATE_GENERAL) { + events.emit(this, "stylesheet-updated", styleSheet); + } + }, + + /** + * Return or create a StyleRuleActor for the given item. + * @param item Either a CSSStyleRule or a DOM element. + */ + _styleRef: function (item) { + if (this.refMap.has(item)) { + return this.refMap.get(item); + } + let actor = StyleRuleActor(this, item); + this.manage(actor); + this.refMap.set(item, actor); + + return actor; + }, + + /** + * Update the association between a StyleRuleActor and its + * corresponding item. This is used when a StyleRuleActor updates + * as style sheet and starts using a new rule. + * + * @param oldItem The old association; either a CSSStyleRule or a + * DOM element. + * @param item Either a CSSStyleRule or a DOM element. + * @param actor a StyleRuleActor + */ + updateStyleRef: function (oldItem, item, actor) { + this.refMap.delete(oldItem); + this.refMap.set(item, actor); + }, + + /** + * Return or create a StyleSheetActor for the given nsIDOMCSSStyleSheet. + * @param {DOMStyleSheet} sheet + * The style sheet to create an actor for. + * @return {StyleSheetActor} + * The actor for this style sheet + */ + _sheetRef: function (sheet) { + let tabActor = this.inspector.tabActor; + let actor = tabActor.createStyleSheetActor(sheet); + return actor; + }, + + /** + * Get the computed style for a node. + * + * @param NodeActor node + * @param object options + * `filter`: A string filter that affects the "matched" handling. + * 'user': Include properties from user style sheets. + * 'ua': Include properties from user and user-agent sheets. + * Default value is 'ua' + * `markMatched`: true if you want the 'matched' property to be added + * when a computed property has been modified by a style included + * by `filter`. + * `onlyMatched`: true if unmatched properties shouldn't be included. + * + * @returns a JSON blob with the following form: + * { + * "property-name": { + * value: "property-value", + * priority: "!important" <optional> + * matched: <true if there are matched selectors for this value> + * }, + * ... + * } + */ + getComputed: function (node, options) { + let ret = Object.create(null); + + this.cssLogic.sourceFilter = options.filter || SharedCssLogic.FILTER.UA; + this.cssLogic.highlight(node.rawNode); + let computed = this.cssLogic.computedStyle || []; + + Array.prototype.forEach.call(computed, name => { + ret[name] = { + value: computed.getPropertyValue(name), + priority: computed.getPropertyPriority(name) || undefined + }; + }); + + if (options.markMatched || options.onlyMatched) { + let matched = this.cssLogic.hasMatchedSelectors(Object.keys(ret)); + for (let key in ret) { + if (matched[key]) { + ret[key].matched = options.markMatched ? true : undefined; + } else if (options.onlyMatched) { + delete ret[key]; + } + } + } + + return ret; + }, + + /** + * Get all the fonts from a page. + * + * @param object options + * `includePreviews`: Whether to also return image previews of the fonts. + * `previewText`: The text to display in the previews. + * `previewFontSize`: The font size of the text in the previews. + * + * @returns object + * object with 'fontFaces', a list of fonts that apply to this node. + */ + getAllUsedFontFaces: function (options) { + let windows = this.inspector.tabActor.windows; + let fontsList = []; + for (let win of windows) { + fontsList = [...fontsList, + ...this.getUsedFontFaces(win.document.body, options)]; + } + return fontsList; + }, + + /** + * Get the font faces used in an element. + * + * @param NodeActor node / actual DOM node + * The node to get fonts from. + * @param object options + * `includePreviews`: Whether to also return image previews of the fonts. + * `previewText`: The text to display in the previews. + * `previewFontSize`: The font size of the text in the previews. + * + * @returns object + * object with 'fontFaces', a list of fonts that apply to this node. + */ + getUsedFontFaces: function (node, options) { + // node.rawNode is defined for NodeActor objects + let actualNode = node.rawNode || node; + let contentDocument = actualNode.ownerDocument; + // We don't get fonts for a node, but for a range + let rng = contentDocument.createRange(); + rng.selectNodeContents(actualNode); + let fonts = DOMUtils.getUsedFontFaces(rng); + let fontsArray = []; + + for (let i = 0; i < fonts.length; i++) { + let font = fonts.item(i); + let fontFace = { + name: font.name, + CSSFamilyName: font.CSSFamilyName, + srcIndex: font.srcIndex, + URI: font.URI, + format: font.format, + localName: font.localName, + metadata: font.metadata + }; + + // If this font comes from a @font-face rule + if (font.rule) { + let styleActor = StyleRuleActor(this, font.rule); + this.manage(styleActor); + fontFace.rule = styleActor; + fontFace.ruleText = font.rule.cssText; + } + + // Get the weight and style of this font for the preview and sort order + let weight = NORMAL_FONT_WEIGHT, style = ""; + if (font.rule) { + weight = font.rule.style.getPropertyValue("font-weight") + || NORMAL_FONT_WEIGHT; + if (weight == "bold") { + weight = BOLD_FONT_WEIGHT; + } else if (weight == "normal") { + weight = NORMAL_FONT_WEIGHT; + } + style = font.rule.style.getPropertyValue("font-style") || ""; + } + fontFace.weight = weight; + fontFace.style = style; + + if (options.includePreviews) { + let opts = { + previewText: options.previewText, + previewFontSize: options.previewFontSize, + fontStyle: weight + " " + style, + fillStyle: options.previewFillStyle + }; + let { dataURL, size } = getFontPreviewData(font.CSSFamilyName, + contentDocument, opts); + fontFace.preview = { + data: LongStringActor(this.conn, dataURL), + size: size + }; + } + fontsArray.push(fontFace); + } + + // @font-face fonts at the top, then alphabetically, then by weight + fontsArray.sort(function (a, b) { + return a.weight > b.weight ? 1 : -1; + }); + fontsArray.sort(function (a, b) { + if (a.CSSFamilyName == b.CSSFamilyName) { + return 0; + } + return a.CSSFamilyName > b.CSSFamilyName ? 1 : -1; + }); + fontsArray.sort(function (a, b) { + if ((a.rule && b.rule) || (!a.rule && !b.rule)) { + return 0; + } + return !a.rule && b.rule ? 1 : -1; + }); + + return fontsArray; + }, + + /** + * Get a list of selectors that match a given property for a node. + * + * @param NodeActor node + * @param string property + * @param object options + * `filter`: A string filter that affects the "matched" handling. + * 'user': Include properties from user style sheets. + * 'ua': Include properties from user and user-agent sheets. + * Default value is 'ua' + * + * @returns a JSON object with the following form: + * { + * // An ordered list of rules that apply + * matched: [{ + * rule: <rule actorid>, + * sourceText: <string>, // The source of the selector, relative + * // to the node in question. + * selector: <string>, // the selector ID that matched + * value: <string>, // the value of the property + * status: <int>, + * // The status of the match - high numbers are better placed + * // to provide styling information: + * // 3: Best match, was used. + * // 2: Matched, but was overridden. + * // 1: Rule from a parent matched. + * // 0: Unmatched (never returned in this API) + * }, ...], + * + * // The full form of any domrule referenced. + * rules: [ <domrule>, ... ], // The full form of any domrule referenced + * + * // The full form of any sheets referenced. + * sheets: [ <domsheet>, ... ] + * } + */ + getMatchedSelectors: function (node, property, options) { + this.cssLogic.sourceFilter = options.filter || SharedCssLogic.FILTER.UA; + this.cssLogic.highlight(node.rawNode); + + let rules = new Set(); + let sheets = new Set(); + + let matched = []; + let propInfo = this.cssLogic.getPropertyInfo(property); + for (let selectorInfo of propInfo.matchedSelectors) { + let cssRule = selectorInfo.selector.cssRule; + let domRule = cssRule.sourceElement || cssRule.domRule; + + let rule = this._styleRef(domRule); + rules.add(rule); + + matched.push({ + rule: rule, + sourceText: this.getSelectorSource(selectorInfo, node.rawNode), + selector: selectorInfo.selector.text, + name: selectorInfo.property, + value: selectorInfo.value, + status: selectorInfo.status + }); + } + + this.expandSets(rules, sheets); + + return { + matched: matched, + rules: [...rules], + sheets: [...sheets] + }; + }, + + // Get a selector source for a CssSelectorInfo relative to a given + // node. + getSelectorSource: function (selectorInfo, relativeTo) { + let result = selectorInfo.selector.text; + if (selectorInfo.elementStyle) { + let source = selectorInfo.sourceElement; + if (source === relativeTo) { + result = "this"; + } else { + result = CssLogic.getShortName(source); + } + result += ".style"; + } + return result; + }, + + /** + * Get the set of styles that apply to a given node. + * @param NodeActor node + * @param object options + * `filter`: A string filter that affects the "matched" handling. + * 'user': Include properties from user style sheets. + * 'ua': Include properties from user and user-agent sheets. + * Default value is 'ua' + * `inherited`: Include styles inherited from parent nodes. + * `matchedSelectors`: Include an array of specific selectors that + * caused this rule to match its node. + */ + getApplied: Task.async(function* (node, options) { + if (!node) { + return {entries: [], rules: [], sheets: []}; + } + + this.cssLogic.highlight(node.rawNode); + let entries = []; + entries = entries.concat(this._getAllElementRules(node, undefined, + options)); + + let result = this.getAppliedProps(node, entries, options); + for (let rule of result.rules) { + // See the comment in |form| to understand this. + yield rule.getAuthoredCssText(); + } + return result; + }), + + _hasInheritedProps: function (style) { + return Array.prototype.some.call(style, prop => { + return DOMUtils.isInheritedProperty(prop); + }); + }, + + isPositionEditable: Task.async(function* (node) { + if (!node || node.rawNode.nodeType !== node.rawNode.ELEMENT_NODE) { + return false; + } + + let props = getDefinedGeometryProperties(node.rawNode); + + // Elements with only `width` and `height` are currently not considered + // editable. + return props.has("top") || + props.has("right") || + props.has("left") || + props.has("bottom"); + }), + + /** + * Helper function for getApplied, gets all the rules from a given + * element. See getApplied for documentation on parameters. + * @param NodeActor node + * @param bool inherited + * @param object options + + * @return Array The rules for a given element. Each item in the + * array has the following signature: + * - rule RuleActor + * - isSystem Boolean + * - inherited Boolean + * - pseudoElement String + */ + _getAllElementRules: function (node, inherited, options) { + let {bindingElement, pseudo} = + CssLogic.getBindingElementAndPseudo(node.rawNode); + let rules = []; + + if (!bindingElement || !bindingElement.style) { + return rules; + } + + let elementStyle = this._styleRef(bindingElement); + let showElementStyles = !inherited && !pseudo; + let showInheritedStyles = inherited && + this._hasInheritedProps(bindingElement.style); + + let rule = { + rule: elementStyle, + pseudoElement: null, + isSystem: false, + inherited: false + }; + + // First any inline styles + if (showElementStyles) { + rules.push(rule); + } + + // Now any inherited styles + if (showInheritedStyles) { + rule.inherited = inherited; + rules.push(rule); + } + + // Add normal rules. Typically this is passing in the node passed into the + // function, unless if that node was ::before/::after. In which case, + // it will pass in the parentNode along with "::before"/"::after". + this._getElementRules(bindingElement, pseudo, inherited, options) + .forEach(oneRule => { + // The only case when there would be a pseudo here is + // ::before/::after, and in this case we want to tell the + // view that it belongs to the element (which is a + // _moz_generated_content native anonymous element). + oneRule.pseudoElement = null; + rules.push(oneRule); + }); + + // Now any pseudos. + if (showElementStyles) { + for (let readPseudo of PSEUDO_ELEMENTS) { + this._getElementRules(bindingElement, readPseudo, inherited, options) + .forEach(oneRule => { + rules.push(oneRule); + }); + } + } + + return rules; + }, + + /** + * Helper function for _getAllElementRules, returns the rules from a given + * element. See getApplied for documentation on parameters. + * @param DOMNode node + * @param string pseudo + * @param DOMNode inherited + * @param object options + * + * @returns Array + */ + _getElementRules: function (node, pseudo, inherited, options) { + let domRules = DOMUtils.getCSSStyleRules(node, pseudo); + if (!domRules) { + return []; + } + + let rules = []; + + // getCSSStyleRules returns ordered from least-specific to + // most-specific. + for (let i = domRules.Count() - 1; i >= 0; i--) { + let domRule = domRules.GetElementAt(i); + + let isSystem = !SharedCssLogic.isContentStylesheet(domRule.parentStyleSheet); + + if (isSystem && options.filter != SharedCssLogic.FILTER.UA) { + continue; + } + + if (inherited) { + // Don't include inherited rules if none of its properties + // are inheritable. + let hasInherited = [...domRule.style].some( + prop => DOMUtils.isInheritedProperty(prop) + ); + if (!hasInherited) { + continue; + } + } + + let ruleActor = this._styleRef(domRule); + rules.push({ + rule: ruleActor, + inherited: inherited, + isSystem: isSystem, + pseudoElement: pseudo + }); + } + return rules; + }, + + /** + * Given a node and a CSS rule, walk up the DOM looking for a + * matching element rule. Return an array of all found entries, in + * the form generated by _getAllElementRules. Note that this will + * always return an array of either zero or one element. + * + * @param {NodeActor} node the node + * @param {CSSStyleRule} filterRule the rule to filter for + * @return {Array} array of zero or one elements; if one, the element + * is the entry as returned by _getAllElementRules. + */ + findEntryMatchingRule: function (node, filterRule) { + const options = {matchedSelectors: true, inherited: true}; + let entries = []; + let parent = this.walker.parentNode(node); + while (parent && parent.rawNode.nodeType != Ci.nsIDOMNode.DOCUMENT_NODE) { + entries = entries.concat(this._getAllElementRules(parent, parent, + options)); + parent = this.walker.parentNode(parent); + } + + return entries.filter(entry => entry.rule.rawRule === filterRule); + }, + + /** + * Helper function for getApplied that fetches a set of style properties that + * apply to the given node and associated rules + * @param NodeActor node + * @param object options + * `filter`: A string filter that affects the "matched" handling. + * 'user': Include properties from user style sheets. + * 'ua': Include properties from user and user-agent sheets. + * Default value is 'ua' + * `inherited`: Include styles inherited from parent nodes. + * `matchedSelectors`: Include an array of specific selectors that + * caused this rule to match its node. + * @param array entries + * List of appliedstyle objects that lists the rules that apply to the + * node. If adding a new rule to the stylesheet, only the new rule entry + * is provided and only the style properties that apply to the new + * rule is fetched. + * @returns Object containing the list of rule entries, rule actors and + * stylesheet actors that applies to the given node and its associated + * rules. + */ + getAppliedProps: function (node, entries, options) { + if (options.inherited) { + let parent = this.walker.parentNode(node); + while (parent && parent.rawNode.nodeType != Ci.nsIDOMNode.DOCUMENT_NODE) { + entries = entries.concat(this._getAllElementRules(parent, parent, + options)); + parent = this.walker.parentNode(parent); + } + } + + if (options.matchedSelectors) { + for (let entry of entries) { + if (entry.rule.type === ELEMENT_STYLE) { + continue; + } + + let domRule = entry.rule.rawRule; + let selectors = CssLogic.getSelectors(domRule); + let element = entry.inherited ? entry.inherited.rawNode : node.rawNode; + + let {bindingElement, pseudo} = + CssLogic.getBindingElementAndPseudo(element); + entry.matchedSelectors = []; + for (let i = 0; i < selectors.length; i++) { + if (DOMUtils.selectorMatchesElement(bindingElement, domRule, i, + pseudo)) { + entry.matchedSelectors.push(selectors[i]); + } + } + } + } + + // Add all the keyframes rule associated with the element + let computedStyle = this.cssLogic.computedStyle; + if (computedStyle) { + let animationNames = computedStyle.animationName.split(","); + animationNames = animationNames.map(name => name.trim()); + + if (animationNames) { + // Traverse through all the available keyframes rule and add + // the keyframes rule that matches the computed animation name + for (let keyframesRule of this.cssLogic.keyframesRules) { + if (animationNames.indexOf(keyframesRule.name) > -1) { + for (let rule of keyframesRule.cssRules) { + entries.push({ + rule: this._styleRef(rule), + keyframes: this._styleRef(keyframesRule) + }); + } + } + } + } + } + + let rules = new Set(); + let sheets = new Set(); + entries.forEach(entry => rules.add(entry.rule)); + this.expandSets(rules, sheets); + + return { + entries: entries, + rules: [...rules], + sheets: [...sheets] + }; + }, + + /** + * Expand Sets of rules and sheets to include all parent rules and sheets. + */ + expandSets: function (ruleSet, sheetSet) { + // Sets include new items in their iteration + for (let rule of ruleSet) { + if (rule.rawRule.parentRule) { + let parent = this._styleRef(rule.rawRule.parentRule); + if (!ruleSet.has(parent)) { + ruleSet.add(parent); + } + } + if (rule.rawRule.parentStyleSheet) { + let parent = this._sheetRef(rule.rawRule.parentStyleSheet); + if (!sheetSet.has(parent)) { + sheetSet.add(parent); + } + } + } + + for (let sheet of sheetSet) { + if (sheet.rawSheet.parentStyleSheet) { + let parent = this._sheetRef(sheet.rawSheet.parentStyleSheet); + if (!sheetSet.has(parent)) { + sheetSet.add(parent); + } + } + } + }, + + /** + * Get layout-related information about a node. + * This method returns an object with properties giving information about + * the node's margin, border, padding and content region sizes, as well + * as information about the type of box, its position, z-index, etc... + * @param {NodeActor} node + * @param {Object} options The only available option is autoMargins. + * If set to true, the element's margins will receive an extra check to see + * whether they are set to "auto" (knowing that the computed-style in this + * case would return "0px"). + * The returned object will contain an extra property (autoMargins) listing + * all margins that are set to auto, e.g. {top: "auto", left: "auto"}. + * @return {Object} + */ + getLayout: function (node, options) { + this.cssLogic.highlight(node.rawNode); + + let layout = {}; + + // First, we update the first part of the box model view, with + // the size of the element. + + let clientRect = node.rawNode.getBoundingClientRect(); + layout.width = parseFloat(clientRect.width.toPrecision(6)); + layout.height = parseFloat(clientRect.height.toPrecision(6)); + + // We compute and update the values of margins & co. + let style = CssLogic.getComputedStyle(node.rawNode); + for (let prop of [ + "position", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "border-top-width", + "border-right-width", + "border-bottom-width", + "border-left-width", + "z-index", + "box-sizing", + "display" + ]) { + layout[prop] = style.getPropertyValue(prop); + } + + if (options.autoMargins) { + layout.autoMargins = this.processMargins(this.cssLogic); + } + + for (let i in this.map) { + let property = this.map[i].property; + this.map[i].value = parseFloat(style.getPropertyValue(property)); + } + + return layout; + }, + + /** + * Find 'auto' margin properties. + */ + processMargins: function (cssLogic) { + let margins = {}; + + for (let prop of ["top", "bottom", "left", "right"]) { + let info = cssLogic.getPropertyInfo("margin-" + prop); + let selectors = info.matchedSelectors; + if (selectors && selectors.length > 0 && selectors[0].value == "auto") { + margins[prop] = "auto"; + } + } + + return margins; + }, + + /** + * On page navigation, tidy up remaining objects. + */ + onFrameUnload: function () { + this.styleElements = new WeakMap(); + }, + + /** + * When a stylesheet is added, handle the related StyleSheetActor to listen for changes. + * @param {StyleSheetActor} actor + * The actor for the added stylesheet. + */ + onStyleSheetAdded: function (actor) { + if (!this._watchedSheets.has(actor)) { + this._watchedSheets.add(actor); + actor.on("style-applied", this._styleApplied); + } + }, + + /** + * Helper function to addNewRule to get or create a style tag in the provided + * document. + * + * @param {Document} document + * The document in which the style element should be appended. + * @returns DOMElement of the style tag + */ + getStyleElement: function (document) { + if (!this.styleElements.has(document)) { + let style = document.createElementNS(XHTML_NS, "style"); + style.setAttribute("type", "text/css"); + document.documentElement.appendChild(style); + this.styleElements.set(document, style); + } + + return this.styleElements.get(document); + }, + + /** + * Helper function for adding a new rule and getting its applied style + * properties + * @param NodeActor node + * @param CSSStyleRule rule + * @returns Object containing its applied style properties + */ + getNewAppliedProps: function (node, rule) { + let ruleActor = this._styleRef(rule); + return this.getAppliedProps(node, [{ rule: ruleActor }], + { matchedSelectors: true }); + }, + + /** + * Adds a new rule, and returns the new StyleRuleActor. + * @param {NodeActor} node + * @param {String} pseudoClasses The list of pseudo classes to append to the + * new selector. + * @param {Boolean} editAuthored + * True if the selector should be updated by editing the + * authored text; false if the selector should be updated via + * CSSOM. + * @returns {StyleRuleActor} the new rule + */ + addNewRule: Task.async(function* (node, pseudoClasses, editAuthored = false) { + let style = this.getStyleElement(node.rawNode.ownerDocument); + let sheet = style.sheet; + let cssRules = sheet.cssRules; + let rawNode = node.rawNode; + let classes = [...rawNode.classList]; + + let selector; + if (rawNode.id) { + selector = "#" + CSS.escape(rawNode.id); + } else if (classes.length > 0) { + selector = "." + classes.map(c => CSS.escape(c)).join("."); + } else { + selector = rawNode.localName; + } + + if (pseudoClasses && pseudoClasses.length > 0) { + selector += pseudoClasses.join(""); + } + + let index = sheet.insertRule(selector + " {}", cssRules.length); + + // If inserting the rule succeeded, go ahead and edit the source + // text if requested. + if (editAuthored) { + let sheetActor = this._sheetRef(sheet); + let {str: authoredText} = yield sheetActor.getText(); + authoredText += "\n" + selector + " {\n" + "}"; + yield sheetActor.update(authoredText, false); + } + + return this.getNewAppliedProps(node, sheet.cssRules.item(index)); + }) +}); +exports.PageStyleActor = PageStyleActor; + +/** + * An actor that represents a CSS style object on the protocol. + * + * We slightly flatten the CSSOM for this actor, it represents + * both the CSSRule and CSSStyle objects in one actor. For nodes + * (which have a CSSStyle but no CSSRule) we create a StyleRuleActor + * with a special rule type (100). + */ +var StyleRuleActor = protocol.ActorClassWithSpec(styleRuleSpec, { + initialize: function (pageStyle, item) { + protocol.Actor.prototype.initialize.call(this, null); + this.pageStyle = pageStyle; + this.rawStyle = item.style; + this._parentSheet = null; + this._onStyleApplied = this._onStyleApplied.bind(this); + + if (item instanceof (Ci.nsIDOMCSSRule)) { + this.type = item.type; + this.rawRule = item; + if ((this.type === Ci.nsIDOMCSSRule.STYLE_RULE || + this.type === Ci.nsIDOMCSSRule.KEYFRAME_RULE) && + this.rawRule.parentStyleSheet) { + this.line = DOMUtils.getRelativeRuleLine(this.rawRule); + this.column = DOMUtils.getRuleColumn(this.rawRule); + this._parentSheet = this.rawRule.parentStyleSheet; + this._computeRuleIndex(); + this.sheetActor = this.pageStyle._sheetRef(this._parentSheet); + this.sheetActor.on("style-applied", this._onStyleApplied); + } + } else { + // Fake a rule + this.type = ELEMENT_STYLE; + this.rawNode = item; + this.rawRule = { + style: item.style, + toString: function () { + return "[element rule " + this.style + "]"; + } + }; + } + }, + + get conn() { + return this.pageStyle.conn; + }, + + destroy: function () { + if (!this.rawStyle) { + return; + } + protocol.Actor.prototype.destroy.call(this); + this.rawStyle = null; + this.pageStyle = null; + this.rawNode = null; + this.rawRule = null; + if (this.sheetActor) { + this.sheetActor.off("style-applied", this._onStyleApplied); + } + }, + + // Objects returned by this actor are owned by the PageStyleActor + // to which this rule belongs. + get marshallPool() { + return this.pageStyle; + }, + + // True if this rule supports as-authored styles, meaning that the + // rule text can be rewritten using setRuleText. + get canSetRuleText() { + return this.type === ELEMENT_STYLE || + (this._parentSheet && + // If a rule does not have source, then it has been modified via + // CSSOM; and we should fall back to non-authored editing. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1224121 + this.sheetActor.allRulesHaveSource() && + // Special case about:PreferenceStyleSheet, as it is generated on + // the fly and the URI is not registered with the about:handler + // https://bugzilla.mozilla.org/show_bug.cgi?id=935803#c37 + this._parentSheet.href !== "about:PreferenceStyleSheet"); + }, + + getDocument: function (sheet) { + let document; + + if (sheet.ownerNode instanceof Ci.nsIDOMHTMLDocument) { + document = sheet.ownerNode; + } else { + document = sheet.ownerNode.ownerDocument; + } + + return document; + }, + + toString: function () { + return "[StyleRuleActor for " + this.rawRule + "]"; + }, + + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let form = { + actor: this.actorID, + type: this.type, + line: this.line || undefined, + column: this.column, + traits: { + // Whether the style rule actor implements the modifySelector2 method + // that allows for unmatched rule to be added + modifySelectorUnmatched: true, + // Whether the style rule actor implements the setRuleText + // method. + canSetRuleText: this.canSetRuleText, + } + }; + + if (this.rawRule.parentRule) { + form.parentRule = + this.pageStyle._styleRef(this.rawRule.parentRule).actorID; + + // CSS rules that we call media rules are STYLE_RULES that are children + // of MEDIA_RULEs. We need to check the parentRule to check if a rule is + // a media rule so we do this here instead of in the switch statement + // below. + if (this.rawRule.parentRule.type === Ci.nsIDOMCSSRule.MEDIA_RULE) { + form.media = []; + for (let i = 0, n = this.rawRule.parentRule.media.length; i < n; i++) { + form.media.push(this.rawRule.parentRule.media.item(i)); + } + } + } + if (this._parentSheet) { + form.parentStyleSheet = + this.pageStyle._sheetRef(this._parentSheet).actorID; + } + + // One tricky thing here is that other methods in this actor must + // ensure that authoredText has been set before |form| is called. + // This has to be treated specially, for now, because we cannot + // synchronously compute the authored text, but |form| also cannot + // return a promise. See bug 1205868. + form.authoredText = this.authoredText; + + switch (this.type) { + case Ci.nsIDOMCSSRule.STYLE_RULE: + form.selectors = CssLogic.getSelectors(this.rawRule); + form.cssText = this.rawStyle.cssText || ""; + break; + case ELEMENT_STYLE: + // Elements don't have a parent stylesheet, and therefore + // don't have an associated URI. Provide a URI for + // those. + let doc = this.rawNode.ownerDocument; + form.href = doc.location ? doc.location.href : ""; + form.cssText = this.rawStyle.cssText || ""; + form.authoredText = this.rawNode.getAttribute("style"); + break; + case Ci.nsIDOMCSSRule.CHARSET_RULE: + form.encoding = this.rawRule.encoding; + break; + case Ci.nsIDOMCSSRule.IMPORT_RULE: + form.href = this.rawRule.href; + break; + case Ci.nsIDOMCSSRule.KEYFRAMES_RULE: + form.cssText = this.rawRule.cssText; + form.name = this.rawRule.name; + break; + case Ci.nsIDOMCSSRule.KEYFRAME_RULE: + form.cssText = this.rawStyle.cssText || ""; + form.keyText = this.rawRule.keyText || ""; + break; + } + + // Parse the text into a list of declarations so the client doesn't have to + // and so that we can safely determine if a declaration is valid rather than + // have the client guess it. + if (form.authoredText || form.cssText) { + let declarations = parseDeclarations(isCssPropertyKnown, + form.authoredText || form.cssText, + true); + form.declarations = declarations.map(decl => { + decl.isValid = DOMUtils.cssPropertyIsValid(decl.name, decl.value); + return decl; + }); + } + + return form; + }, + + /** + * Send an event notifying that the location of the rule has + * changed. + * + * @param {Number} line the new line number + * @param {Number} column the new column number + */ + _notifyLocationChanged: function (line, column) { + events.emit(this, "location-changed", line, column); + }, + + /** + * Compute the index of this actor's raw rule in its parent style + * sheet. The index is a vector where each element is the index of + * a given CSS rule in its parent. A vector is used to support + * nested rules. + */ + _computeRuleIndex: function () { + let rule = this.rawRule; + let result = []; + + while (rule) { + let cssRules; + if (rule.parentRule) { + cssRules = rule.parentRule.cssRules; + } else { + cssRules = rule.parentStyleSheet.cssRules; + } + + let found = false; + for (let i = 0; i < cssRules.length; i++) { + if (rule === cssRules.item(i)) { + found = true; + result.unshift(i); + break; + } + } + + if (!found) { + this._ruleIndex = null; + return; + } + + rule = rule.parentRule; + } + + this._ruleIndex = result; + }, + + /** + * Get the rule corresponding to |this._ruleIndex| from the given + * style sheet. + * + * @param {DOMStyleSheet} sheet + * The style sheet. + * @return {CSSStyleRule} the rule corresponding to + * |this._ruleIndex| + */ + _getRuleFromIndex: function (parentSheet) { + let currentRule = null; + for (let i of this._ruleIndex) { + if (currentRule === null) { + currentRule = parentSheet.cssRules[i]; + } else { + currentRule = currentRule.cssRules.item(i); + } + } + return currentRule; + }, + + /** + * This is attached to the parent style sheet actor's + * "style-applied" event. + */ + _onStyleApplied: function (kind) { + if (kind === UPDATE_GENERAL) { + // A general change means that the rule actors are invalidated, + // so stop listening to events now. + if (this.sheetActor) { + this.sheetActor.off("style-applied", this._onStyleApplied); + } + } else if (this._ruleIndex) { + // The sheet was updated by this actor, in a way that preserves + // the rules. Now, recompute our new rule from the style sheet, + // so that we aren't left with a reference to a dangling rule. + let oldRule = this.rawRule; + this.rawRule = this._getRuleFromIndex(this._parentSheet); + // Also tell the page style so that future calls to _styleRef + // return the same StyleRuleActor. + this.pageStyle.updateStyleRef(oldRule, this.rawRule, this); + let line = DOMUtils.getRelativeRuleLine(this.rawRule); + let column = DOMUtils.getRuleColumn(this.rawRule); + if (line !== this.line || column !== this.column) { + this._notifyLocationChanged(line, column); + } + this.line = line; + this.column = column; + } + }, + + /** + * Return a promise that resolves to the authored form of a rule's + * text, if available. If the authored form is not available, the + * returned promise simply resolves to the empty string. If the + * authored form is available, this also sets |this.authoredText|. + * The authored text will include invalid and otherwise ignored + * properties. + */ + getAuthoredCssText: function () { + if (!this.canSetRuleText || + (this.type !== Ci.nsIDOMCSSRule.STYLE_RULE && + this.type !== Ci.nsIDOMCSSRule.KEYFRAME_RULE)) { + return promise.resolve(""); + } + + if (typeof this.authoredText === "string") { + return promise.resolve(this.authoredText); + } + + let parentStyleSheet = + this.pageStyle._sheetRef(this._parentSheet); + return parentStyleSheet.getText().then((longStr) => { + let cssText = longStr.str; + let {text} = getRuleText(cssText, this.line, this.column); + + // Cache the result on the rule actor to avoid parsing again next time + this.authoredText = text; + return this.authoredText; + }); + }, + + /** + * Set the contents of the rule. This rewrites the rule in the + * stylesheet and causes it to be re-evaluated. + * + * @param {String} newText the new text of the rule + * @returns the rule with updated properties + */ + setRuleText: Task.async(function* (newText) { + if (!this.canSetRuleText) { + throw new Error("invalid call to setRuleText"); + } + + if (this.type === ELEMENT_STYLE) { + // For element style rules, set the node's style attribute. + this.rawNode.setAttribute("style", newText); + } else { + // For stylesheet rules, set the text in the stylesheet. + let parentStyleSheet = this.pageStyle._sheetRef(this._parentSheet); + let {str: cssText} = yield parentStyleSheet.getText(); + + let {offset, text} = getRuleText(cssText, this.line, this.column); + cssText = cssText.substring(0, offset) + newText + + cssText.substring(offset + text.length); + + yield parentStyleSheet.update(cssText, false, UPDATE_PRESERVING_RULES); + } + + this.authoredText = newText; + + return this; + }), + + /** + * Modify a rule's properties. Passed an array of modifications: + * { + * type: "set", + * name: <string>, + * value: <string>, + * priority: <optional string> + * } + * or + * { + * type: "remove", + * name: <string>, + * } + * + * @returns the rule with updated properties + */ + modifyProperties: function (modifications) { + // Use a fresh element for each call to this function to prevent side + // effects that pop up based on property values that were already set on the + // element. + + let document; + if (this.rawNode) { + document = this.rawNode.ownerDocument; + } else { + let parentStyleSheet = this._parentSheet; + while (parentStyleSheet.ownerRule && + parentStyleSheet.ownerRule instanceof Ci.nsIDOMCSSImportRule) { + parentStyleSheet = parentStyleSheet.ownerRule.parentStyleSheet; + } + + document = this.getDocument(parentStyleSheet); + } + + let tempElement = document.createElementNS(XHTML_NS, "div"); + + for (let mod of modifications) { + if (mod.type === "set") { + tempElement.style.setProperty(mod.name, mod.value, mod.priority || ""); + this.rawStyle.setProperty(mod.name, + tempElement.style.getPropertyValue(mod.name), mod.priority || ""); + } else if (mod.type === "remove") { + this.rawStyle.removeProperty(mod.name); + } + } + + return this; + }, + + /** + * Helper function for modifySelector and modifySelector2, inserts the new + * rule with the new selector into the parent style sheet and removes the + * current rule. Returns the newly inserted css rule or null if the rule is + * unsuccessfully inserted to the parent style sheet. + * + * @param {String} value + * The new selector value + * @param {Boolean} editAuthored + * True if the selector should be updated by editing the + * authored text; false if the selector should be updated via + * CSSOM. + * + * @returns {CSSRule} + * The new CSS rule added + */ + _addNewSelector: Task.async(function* (value, editAuthored) { + let rule = this.rawRule; + let parentStyleSheet = this._parentSheet; + + // We know the selector modification is ok, so if the client asked + // for the authored text to be edited, do it now. + if (editAuthored) { + let document = this.getDocument(this._parentSheet); + try { + document.querySelector(value); + } catch (e) { + return null; + } + + let sheetActor = this.pageStyle._sheetRef(parentStyleSheet); + let {str: authoredText} = yield sheetActor.getText(); + let [startOffset, endOffset] = getSelectorOffsets(authoredText, this.line, + this.column); + authoredText = authoredText.substring(0, startOffset) + value + + authoredText.substring(endOffset); + yield sheetActor.update(authoredText, false, UPDATE_PRESERVING_RULES); + } else { + let cssRules = parentStyleSheet.cssRules; + let cssText = rule.cssText; + let selectorText = rule.selectorText; + + for (let i = 0; i < cssRules.length; i++) { + if (rule === cssRules.item(i)) { + try { + // Inserts the new style rule into the current style sheet and + // delete the current rule + let ruleText = cssText.slice(selectorText.length).trim(); + parentStyleSheet.insertRule(value + " " + ruleText, i); + parentStyleSheet.deleteRule(i + 1); + break; + } catch (e) { + // The selector could be invalid, or the rule could fail to insert. + return null; + } + } + } + } + + return this._getRuleFromIndex(parentStyleSheet); + }), + + /** + * Modify the current rule's selector by inserting a new rule with the new + * selector value and removing the current rule. + * + * Note this method was kept for backward compatibility, but unmatched rules + * support was added in FF41. + * + * @param string value + * The new selector value + * @returns boolean + * Returns a boolean if the selector in the stylesheet was modified, + * and false otherwise + */ + modifySelector: Task.async(function* (value) { + if (this.type === ELEMENT_STYLE) { + return false; + } + + let document = this.getDocument(this._parentSheet); + // Extract the selector, and pseudo elements and classes + let [selector] = value.split(/(:{1,2}.+$)/); + let selectorElement; + + try { + selectorElement = document.querySelector(selector); + } catch (e) { + return false; + } + + // Check if the selector is valid and not the same as the original + // selector + if (selectorElement && this.rawRule.selectorText !== value) { + yield this._addNewSelector(value, false); + return true; + } + return false; + }), + + /** + * Modify the current rule's selector by inserting a new rule with the new + * selector value and removing the current rule. + * + * In contrast with the modifySelector method which was used before FF41, + * this method also returns information about the new rule and applied style + * so that consumers can immediately display the new rule, whether or not the + * selector matches the current element without having to refresh the whole + * list. + * + * @param {DOMNode} node + * The current selected element + * @param {String} value + * The new selector value + * @param {Boolean} editAuthored + * True if the selector should be updated by editing the + * authored text; false if the selector should be updated via + * CSSOM. + * @returns {Object} + * Returns an object that contains the applied style properties of the + * new rule and a boolean indicating whether or not the new selector + * matches the current selected element + */ + modifySelector2: function (node, value, editAuthored = false) { + if (this.type === ELEMENT_STYLE || + this.rawRule.selectorText === value) { + return { ruleProps: null, isMatching: true }; + } + + let selectorPromise = this._addNewSelector(value, editAuthored); + + if (editAuthored) { + selectorPromise = selectorPromise.then((newCssRule) => { + if (newCssRule) { + let style = this.pageStyle._styleRef(newCssRule); + // See the comment in |form| to understand this. + return style.getAuthoredCssText().then(() => newCssRule); + } + return newCssRule; + }); + } + + return selectorPromise.then((newCssRule) => { + let ruleProps = null; + let isMatching = false; + + if (newCssRule) { + let ruleEntry = this.pageStyle.findEntryMatchingRule(node, newCssRule); + if (ruleEntry.length === 1) { + ruleProps = + this.pageStyle.getAppliedProps(node, ruleEntry, + { matchedSelectors: true }); + } else { + ruleProps = this.pageStyle.getNewAppliedProps(node, newCssRule); + } + + isMatching = ruleProps.entries.some((ruleProp) => + ruleProp.matchedSelectors.length > 0); + } + + return { ruleProps, isMatching }; + }); + } +}); + +/** + * Helper function for getting an image preview of the given font. + * + * @param font {string} + * Name of font to preview + * @param doc {Document} + * Document to use to render font + * @param options {object} + * Object with options 'previewText' and 'previewFontSize' + * + * @return dataUrl + * The data URI of the font preview image + */ +function getFontPreviewData(font, doc, options) { + options = options || {}; + let previewText = options.previewText || FONT_PREVIEW_TEXT; + let previewFontSize = options.previewFontSize || FONT_PREVIEW_FONT_SIZE; + let fillStyle = options.fillStyle || FONT_PREVIEW_FILLSTYLE; + let fontStyle = options.fontStyle || ""; + + let canvas = doc.createElementNS(XHTML_NS, "canvas"); + let ctx = canvas.getContext("2d"); + let fontValue = fontStyle + " " + previewFontSize + "px " + font + ", serif"; + + // Get the correct preview text measurements and set the canvas dimensions + ctx.font = fontValue; + ctx.fillStyle = fillStyle; + let textWidth = ctx.measureText(previewText).width; + + canvas.width = textWidth * 2 + FONT_PREVIEW_OFFSET * 2; + canvas.height = previewFontSize * 3; + + // we have to reset these after changing the canvas size + ctx.font = fontValue; + ctx.fillStyle = fillStyle; + + // Oversample the canvas for better text quality + ctx.textBaseline = "top"; + ctx.scale(2, 2); + ctx.fillText(previewText, + FONT_PREVIEW_OFFSET, + Math.round(previewFontSize / 3)); + + let dataURL = canvas.toDataURL("image/png"); + + return { + dataURL: dataURL, + size: textWidth + FONT_PREVIEW_OFFSET * 2 + }; +} + +exports.getFontPreviewData = getFontPreviewData; + +/** + * Get the text content of a rule given some CSS text, a line and a column + * Consider the following example: + * body { + * color: red; + * } + * p { + * line-height: 2em; + * color: blue; + * } + * Calling the function with the whole text above and line=4 and column=1 would + * return "line-height: 2em; color: blue;" + * @param {String} initialText + * @param {Number} line (1-indexed) + * @param {Number} column (1-indexed) + * @return {object} An object of the form {offset: number, text: string} + * The offset is the index into the input string where + * the rule text started. The text is the content of + * the rule. + */ +function getRuleText(initialText, line, column) { + if (typeof line === "undefined" || typeof column === "undefined") { + throw new Error("Location information is missing"); + } + + let {offset: textOffset, text} = + getTextAtLineColumn(initialText, line, column); + let lexer = DOMUtils.getCSSLexer(text); + + // Search forward for the opening brace. + while (true) { + let token = lexer.nextToken(); + if (!token) { + throw new Error("couldn't find start of the rule"); + } + if (token.tokenType === "symbol" && token.text === "{") { + break; + } + } + + // Now collect text until we see the matching close brace. + let braceDepth = 1; + let startOffset, endOffset; + while (true) { + let token = lexer.nextToken(); + if (!token) { + break; + } + if (startOffset === undefined) { + startOffset = token.startOffset; + } + if (token.tokenType === "symbol") { + if (token.text === "{") { + ++braceDepth; + } else if (token.text === "}") { + --braceDepth; + if (braceDepth == 0) { + break; + } + } + } + endOffset = token.endOffset; + } + + // If the rule was of the form "selector {" with no closing brace + // and no properties, just return an empty string. + if (startOffset === undefined) { + return {offset: 0, text: ""}; + } + // If the input didn't have any tokens between the braces (e.g., + // "div {}"), then the endOffset won't have been set yet; so account + // for that here. + if (endOffset === undefined) { + endOffset = startOffset; + } + + // Note that this approach will preserve comments, despite the fact + // that cssTokenizer skips them. + return {offset: textOffset + startOffset, + text: text.substring(startOffset, endOffset)}; +} + +exports.getRuleText = getRuleText; + +/** + * Compute the start and end offsets of a rule's selector text, given + * the CSS text and the line and column at which the rule begins. + * @param {String} initialText + * @param {Number} line (1-indexed) + * @param {Number} column (1-indexed) + * @return {array} An array with two elements: [startOffset, endOffset]. + * The elements mark the bounds in |initialText| of + * the CSS rule's selector. + */ +function getSelectorOffsets(initialText, line, column) { + if (typeof line === "undefined" || typeof column === "undefined") { + throw new Error("Location information is missing"); + } + + let {offset: textOffset, text} = + getTextAtLineColumn(initialText, line, column); + let lexer = DOMUtils.getCSSLexer(text); + + // Search forward for the opening brace. + let endOffset; + while (true) { + let token = lexer.nextToken(); + if (!token) { + break; + } + if (token.tokenType === "symbol" && token.text === "{") { + if (endOffset === undefined) { + break; + } + return [textOffset, textOffset + endOffset]; + } + // Preserve comments and whitespace just before the "{". + if (token.tokenType !== "comment" && token.tokenType !== "whitespace") { + endOffset = token.endOffset; + } + } + + throw new Error("could not find bounds of rule"); +} + +/** + * Return the offset and substring of |text| that starts at the given + * line and column. + * @param {String} text + * @param {Number} line (1-indexed) + * @param {Number} column (1-indexed) + * @return {object} An object of the form {offset: number, text: string}, + * where the offset is the offset into the input string + * where the text starts, and where text is the text. + */ +function getTextAtLineColumn(text, line, column) { + let offset; + if (line > 1) { + let rx = new RegExp("(?:.*(?:\\r\\n|\\n|\\r|\\f)){" + (line - 1) + "}"); + offset = rx.exec(text)[0].length; + } else { + offset = 0; + } + offset += column - 1; + return {offset: offset, text: text.substr(offset) }; +} + +exports.getTextAtLineColumn = getTextAtLineColumn; diff --git a/devtools/server/actors/stylesheets.js b/devtools/server/actors/stylesheets.js new file mode 100644 index 000000000..f20634e6c --- /dev/null +++ b/devtools/server/actors/stylesheets.js @@ -0,0 +1,982 @@ +/* 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 {Cc, Ci} = require("chrome"); +const Services = require("Services"); +const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); +const promise = require("promise"); +const {Task} = require("devtools/shared/task"); +const events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const {LongStringActor} = require("devtools/server/actors/string"); +const {fetch} = require("devtools/shared/DevToolsUtils"); +const {listenOnce} = require("devtools/shared/async-utils"); +const {originalSourceSpec, mediaRuleSpec, styleSheetSpec, + styleSheetsSpec} = require("devtools/shared/specs/stylesheets"); +const {SourceMapConsumer} = require("source-map"); +const { installHelperSheet, + addPseudoClassLock, removePseudoClassLock } = require("devtools/server/actors/highlighters/utils/markup"); + +loader.lazyGetter(this, "CssLogic", () => require("devtools/shared/inspector/css-logic")); + +XPCOMUtils.defineLazyGetter(this, "DOMUtils", function () { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); +}); + +var TRANSITION_PSEUDO_CLASS = ":-moz-styleeditor-transitioning"; +var TRANSITION_DURATION_MS = 500; +var TRANSITION_BUFFER_MS = 1000; +var TRANSITION_RULE_SELECTOR = +`:root${TRANSITION_PSEUDO_CLASS}, :root${TRANSITION_PSEUDO_CLASS} *`; +var TRANSITION_RULE = `${TRANSITION_RULE_SELECTOR} { + transition-duration: ${TRANSITION_DURATION_MS}ms !important; + transition-delay: 0ms !important; + transition-timing-function: ease-out !important; + transition-property: all !important; +}`; + +var LOAD_ERROR = "error-load"; + +// The possible kinds of style-applied events. +// UPDATE_PRESERVING_RULES means that the update is guaranteed to +// preserve the number and order of rules on the style sheet. +// UPDATE_GENERAL covers any other kind of change to the style sheet. +const UPDATE_PRESERVING_RULES = 0; +exports.UPDATE_PRESERVING_RULES = UPDATE_PRESERVING_RULES; +const UPDATE_GENERAL = 1; +exports.UPDATE_GENERAL = UPDATE_GENERAL; + +// If the user edits a style sheet, we stash a copy of the edited text +// here, keyed by the style sheet. This way, if the tools are closed +// and then reopened, the edited text will be available. A weak map +// is used so that navigation by the user will eventually cause the +// edited text to be collected. +let modifiedStyleSheets = new WeakMap(); + +/** + * Actor representing an original source of a style sheet that was specified + * in a source map. + */ +var OriginalSourceActor = protocol.ActorClassWithSpec(originalSourceSpec, { + initialize: function (aUrl, aSourceMap, aParentActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.url = aUrl; + this.sourceMap = aSourceMap; + this.parentActor = aParentActor; + this.conn = this.parentActor.conn; + + this.text = null; + }, + + form: function () { + return { + actor: this.actorID, // actorID is set when it's added to a pool + url: this.url, + relatedStyleSheet: this.parentActor.form() + }; + }, + + _getText: function () { + if (this.text) { + return promise.resolve(this.text); + } + let content = this.sourceMap.sourceContentFor(this.url); + if (content) { + this.text = content; + return promise.resolve(content); + } + let options = { + policy: Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET, + window: this.window + }; + return fetch(this.url, options).then(({content}) => { + this.text = content; + return content; + }); + }, + + /** + * Protocol method to get the text of this source. + */ + getText: function () { + return this._getText().then((text) => { + return new LongStringActor(this.conn, text || ""); + }); + } +}); + +/** + * A MediaRuleActor lives on the server and provides access to properties + * of a DOM @media rule and emits events when it changes. + */ +var MediaRuleActor = protocol.ActorClassWithSpec(mediaRuleSpec, { + get window() { + return this.parentActor.window; + }, + + get document() { + return this.window.document; + }, + + get matches() { + return this.mql ? this.mql.matches : null; + }, + + initialize: function (aMediaRule, aParentActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.rawRule = aMediaRule; + this.parentActor = aParentActor; + this.conn = this.parentActor.conn; + + this._matchesChange = this._matchesChange.bind(this); + + this.line = DOMUtils.getRuleLine(aMediaRule); + this.column = DOMUtils.getRuleColumn(aMediaRule); + + try { + this.mql = this.window.matchMedia(aMediaRule.media.mediaText); + } catch (e) { + } + + if (this.mql) { + this.mql.addListener(this._matchesChange); + } + }, + + destroy: function () + { + if (this.mql) { + this.mql.removeListener(this._matchesChange); + } + + protocol.Actor.prototype.destroy.call(this); + }, + + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let form = { + actor: this.actorID, // actorID is set when this is added to a pool + mediaText: this.rawRule.media.mediaText, + conditionText: this.rawRule.conditionText, + matches: this.matches, + line: this.line, + column: this.column, + parentStyleSheet: this.parentActor.actorID + }; + + return form; + }, + + _matchesChange: function () { + events.emit(this, "matches-change", this.matches); + } +}); + +/** + * A StyleSheetActor represents a stylesheet on the server. + */ +var StyleSheetActor = protocol.ActorClassWithSpec(styleSheetSpec, { + /* List of original sources that generated this stylesheet */ + _originalSources: null, + + toString: function () { + return "[StyleSheetActor " + this.actorID + "]"; + }, + + /** + * Window of target + */ + get window() { + return this._window || this.parentActor.window; + }, + + /** + * Document of target. + */ + get document() { + return this.window.document; + }, + + get ownerNode() { + return this.rawSheet.ownerNode; + }, + + /** + * URL of underlying stylesheet. + */ + get href() { + return this.rawSheet.href; + }, + + /** + * Returns the stylesheet href or the document href if the sheet is inline. + */ + get safeHref() { + let href = this.href; + if (!href) { + if (this.ownerNode instanceof Ci.nsIDOMHTMLDocument) { + href = this.ownerNode.location.href; + } else if (this.ownerNode.ownerDocument && + this.ownerNode.ownerDocument.location) { + href = this.ownerNode.ownerDocument.location.href; + } + } + return href; + }, + + /** + * Retrieve the index (order) of stylesheet in the document. + * + * @return number + */ + get styleSheetIndex() + { + if (this._styleSheetIndex == -1) { + for (let i = 0; i < this.document.styleSheets.length; i++) { + if (this.document.styleSheets[i] == this.rawSheet) { + this._styleSheetIndex = i; + break; + } + } + } + return this._styleSheetIndex; + }, + + destroy: function () { + if (this._transitionTimeout) { + this.window.clearTimeout(this._transitionTimeout); + removePseudoClassLock( + this.document.documentElement, TRANSITION_PSEUDO_CLASS); + } + }, + + /** + * Since StyleSheetActor doesn't have a protocol.js parent actor that take + * care of its lifetime, implementing disconnect is required to cleanup. + */ + disconnect: function () { + this.destroy(); + }, + + initialize: function (aStyleSheet, aParentActor, aWindow) { + protocol.Actor.prototype.initialize.call(this, null); + + this.rawSheet = aStyleSheet; + this.parentActor = aParentActor; + this.conn = this.parentActor.conn; + + this._window = aWindow; + + // text and index are unknown until source load + this.text = null; + this._styleSheetIndex = -1; + }, + + /** + * Test whether all the rules in this sheet have associated source. + * @return {Boolean} true if all the rules have source; false if + * some rule was created via CSSOM. + */ + allRulesHaveSource: function () { + let rules; + try { + rules = this.rawSheet.cssRules; + } catch (e) { + // sheet isn't loaded yet + return true; + } + + for (let i = 0; i < rules.length; i++) { + let rule = rules[i]; + if (DOMUtils.getRelativeRuleLine(rule) === 0) { + return false; + } + } + + return true; + }, + + /** + * Get the raw stylesheet's cssRules once the sheet has been loaded. + * + * @return {Promise} + * Promise that resolves with a CSSRuleList + */ + getCSSRules: function () { + let rules; + try { + rules = this.rawSheet.cssRules; + } + catch (e) { + // sheet isn't loaded yet + } + + if (rules) { + return promise.resolve(rules); + } + + if (!this.ownerNode) { + return promise.resolve([]); + } + + if (this._cssRules) { + return this._cssRules; + } + + let deferred = promise.defer(); + + let onSheetLoaded = (event) => { + this.ownerNode.removeEventListener("load", onSheetLoaded, false); + + deferred.resolve(this.rawSheet.cssRules); + }; + + this.ownerNode.addEventListener("load", onSheetLoaded, false); + + // cache so we don't add many listeners if this is called multiple times. + this._cssRules = deferred.promise; + + return this._cssRules; + }, + + /** + * Get the current state of the actor + * + * @return {object} + * With properties of the underlying stylesheet, plus 'text', + * 'styleSheetIndex' and 'parentActor' if it's @imported + */ + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let docHref; + if (this.ownerNode) { + if (this.ownerNode instanceof Ci.nsIDOMHTMLDocument) { + docHref = this.ownerNode.location.href; + } + else if (this.ownerNode.ownerDocument && this.ownerNode.ownerDocument.location) { + docHref = this.ownerNode.ownerDocument.location.href; + } + } + + let form = { + actor: this.actorID, // actorID is set when this actor is added to a pool + href: this.href, + nodeHref: docHref, + disabled: this.rawSheet.disabled, + title: this.rawSheet.title, + system: !CssLogic.isContentStylesheet(this.rawSheet), + styleSheetIndex: this.styleSheetIndex + }; + + try { + form.ruleCount = this.rawSheet.cssRules.length; + } + catch (e) { + // stylesheet had an @import rule that wasn't loaded yet + this.getCSSRules().then(() => { + this._notifyPropertyChanged("ruleCount"); + }); + } + return form; + }, + + /** + * Toggle the disabled property of the style sheet + * + * @return {object} + * 'disabled' - the disabled state after toggling. + */ + toggleDisabled: function () { + this.rawSheet.disabled = !this.rawSheet.disabled; + this._notifyPropertyChanged("disabled"); + + return this.rawSheet.disabled; + }, + + /** + * Send an event notifying that a property of the stylesheet + * has changed. + * + * @param {string} property + * Name of the changed property + */ + _notifyPropertyChanged: function (property) { + events.emit(this, "property-change", property, this.form()[property]); + }, + + /** + * Protocol method to get the text of this stylesheet. + */ + getText: function () { + return this._getText().then((text) => { + return new LongStringActor(this.conn, text || ""); + }); + }, + + /** + * Fetch the text for this stylesheet from the cache or network. Return + * cached text if it's already been fetched. + * + * @return {Promise} + * Promise that resolves with a string text of the stylesheet. + */ + _getText: function () { + if (typeof this.text === "string") { + return promise.resolve(this.text); + } + + let cssText = modifiedStyleSheets.get(this.rawSheet); + if (cssText !== undefined) { + this.text = cssText; + return promise.resolve(cssText); + } + + if (!this.href) { + // this is an inline <style> sheet + let content = this.ownerNode.textContent; + this.text = content; + return promise.resolve(content); + } + + let options = { + loadFromCache: true, + policy: Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET, + charset: this._getCSSCharset() + }; + + // Bug 1282660 - We use the system principal to load the default internal + // stylesheets instead of the content principal since such stylesheets + // require system principal to load. At meanwhile, we strip the loadGroup + // for preventing the assertion of the userContextId mismatching. + // The default internal stylesheets load from the 'resource:' URL. + // Bug 1287607, 1291321 - 'chrome' and 'file' protocols should also be handled in the + // same way. + if (!/^(chrome|file|resource):\/\//.test(this.href)) { + options.window = this.window; + options.principal = this.document.nodePrincipal; + } + + return fetch(this.href, options).then(({ content }) => { + this.text = content; + return content; + }); + }, + + /** + * Protocol method to get the original source (actors) for this + * stylesheet if it has uses source maps. + */ + getOriginalSources: function () { + if (this._originalSources) { + return promise.resolve(this._originalSources); + } + return this._fetchOriginalSources(); + }, + + /** + * Fetch the original sources (actors) for this style sheet using its + * source map. If they've already been fetched, returns cached array. + * + * @return {Promise} + * Promise that resolves with an array of OriginalSourceActors + */ + _fetchOriginalSources: function () { + this._clearOriginalSources(); + this._originalSources = []; + + return this.getSourceMap().then((sourceMap) => { + if (!sourceMap) { + return null; + } + for (let url of sourceMap.sources) { + let actor = new OriginalSourceActor(url, sourceMap, this); + + this.manage(actor); + this._originalSources.push(actor); + } + return this._originalSources; + }); + }, + + /** + * Get the SourceMapConsumer for this stylesheet's source map, if + * it exists. Saves the consumer for later queries. + * + * @return {Promise} + * A promise that resolves with a SourceMapConsumer, or null. + */ + getSourceMap: function () { + if (this._sourceMap) { + return this._sourceMap; + } + return this._fetchSourceMap(); + }, + + /** + * Fetch the source map for this stylesheet. + * + * @return {Promise} + * A promise that resolves with a SourceMapConsumer, or null. + */ + _fetchSourceMap: function () { + let deferred = promise.defer(); + + this._getText().then(sheetContent => { + let url = this._extractSourceMapUrl(sheetContent); + if (!url) { + // no source map for this stylesheet + deferred.resolve(null); + return; + } + + url = normalize(url, this.safeHref); + let options = { + loadFromCache: false, + policy: Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET, + window: this.window + }; + + let map = fetch(url, options).then(({content}) => { + // Fetching the source map might have failed with a 404 or other. When + // this happens, SourceMapConsumer may fail with a JSON.parse error. + let consumer; + try { + consumer = new SourceMapConsumer(content); + } catch (e) { + deferred.reject(new Error( + `Source map at ${url} not found or invalid`)); + return null; + } + this._setSourceMapRoot(consumer, url, this.safeHref); + this._sourceMap = promise.resolve(consumer); + + deferred.resolve(consumer); + return consumer; + }, deferred.reject); + + this._sourceMap = map; + }, deferred.reject); + + return deferred.promise; + }, + + /** + * Clear and unmanage the original source actors for this stylesheet. + */ + _clearOriginalSources: function () { + for (actor in this._originalSources) { + this.unmanage(actor); + } + this._originalSources = null; + }, + + /** + * Sets the source map's sourceRoot to be relative to the source map url. + */ + _setSourceMapRoot: function (aSourceMap, aAbsSourceMapURL, aScriptURL) { + if (aScriptURL.startsWith("blob:")) { + aScriptURL = aScriptURL.replace("blob:", ""); + } + const base = dirname( + aAbsSourceMapURL.startsWith("data:") + ? aScriptURL + : aAbsSourceMapURL); + aSourceMap.sourceRoot = aSourceMap.sourceRoot + ? normalize(aSourceMap.sourceRoot, base) + : base; + }, + + /** + * Get the source map url specified in the text of a stylesheet. + * + * @param {string} content + * The text of the style sheet. + * @return {string} + * Url of source map. + */ + _extractSourceMapUrl: function (content) { + var matches = /sourceMappingURL\=([^\s\*]*)/.exec(content); + if (matches) { + return matches[1]; + } + return null; + }, + + /** + * Protocol method that gets the location in the original source of a + * line, column pair in this stylesheet, if its source mapped, otherwise + * a promise of the same location. + */ + getOriginalLocation: function (line, column) { + return this.getSourceMap().then((sourceMap) => { + if (sourceMap) { + return sourceMap.originalPositionFor({ line: line, column: column }); + } + return { + fromSourceMap: false, + source: this.href, + line: line, + column: column + }; + }); + }, + + /** + * Protocol method to get the media rules for the stylesheet. + */ + getMediaRules: function () { + return this._getMediaRules(); + }, + + /** + * Get all the @media rules in this stylesheet. + * + * @return {promise} + * A promise that resolves with an array of MediaRuleActors. + */ + _getMediaRules: function () { + return this.getCSSRules().then((rules) => { + let mediaRules = []; + for (let i = 0; i < rules.length; i++) { + let rule = rules[i]; + if (rule.type != Ci.nsIDOMCSSRule.MEDIA_RULE) { + continue; + } + let actor = new MediaRuleActor(rule, this); + this.manage(actor); + + mediaRules.push(actor); + } + return mediaRules; + }); + }, + + /** + * Get the charset of the stylesheet according to the character set rules + * defined in <http://www.w3.org/TR/CSS2/syndata.html#charset>. + * Note that some of the algorithm is implemented in DevToolsUtils.fetch. + */ + _getCSSCharset: function () + { + let sheet = this.rawSheet; + if (sheet) { + // Do we have a @charset rule in the stylesheet? + // step 2 of syndata.html (without the BOM check). + if (sheet.cssRules) { + let rules = sheet.cssRules; + if (rules.length + && rules.item(0).type == Ci.nsIDOMCSSRule.CHARSET_RULE) { + return rules.item(0).encoding; + } + } + + // step 3: charset attribute of <link> or <style> element, if it exists + if (sheet.ownerNode && sheet.ownerNode.getAttribute) { + let linkCharset = sheet.ownerNode.getAttribute("charset"); + if (linkCharset != null) { + return linkCharset; + } + } + + // step 4 (1 of 2): charset of referring stylesheet. + let parentSheet = sheet.parentStyleSheet; + if (parentSheet && parentSheet.cssRules && + parentSheet.cssRules[0].type == Ci.nsIDOMCSSRule.CHARSET_RULE) { + return parentSheet.cssRules[0].encoding; + } + + // step 4 (2 of 2): charset of referring document. + if (sheet.ownerNode && sheet.ownerNode.ownerDocument.characterSet) { + return sheet.ownerNode.ownerDocument.characterSet; + } + } + + // step 5: default to utf-8. + return "UTF-8"; + }, + + /** + * Update the style sheet in place with new text. + * + * @param {object} request + * 'text' - new text + * 'transition' - whether to do CSS transition for change. + * 'kind' - either UPDATE_PRESERVING_RULES or UPDATE_GENERAL + */ + update: function (text, transition, kind = UPDATE_GENERAL) { + DOMUtils.parseStyleSheet(this.rawSheet, text); + + modifiedStyleSheets.set(this.rawSheet, text); + + this.text = text; + + this._notifyPropertyChanged("ruleCount"); + + if (transition) { + this._insertTransistionRule(kind); + } + else { + events.emit(this, "style-applied", kind, this); + } + + this._getMediaRules().then((rules) => { + events.emit(this, "media-rules-changed", rules); + }); + }, + + /** + * Insert a catch-all transition rule into the document. Set a timeout + * to remove the rule after a certain time. + */ + _insertTransistionRule: function (kind) { + addPseudoClassLock(this.document.documentElement, TRANSITION_PSEUDO_CLASS); + + // We always add the rule since we've just reset all the rules + this.rawSheet.insertRule(TRANSITION_RULE, this.rawSheet.cssRules.length); + + // Set up clean up and commit after transition duration (+buffer) + // @see _onTransitionEnd + this.window.clearTimeout(this._transitionTimeout); + this._transitionTimeout = this.window.setTimeout(this._onTransitionEnd.bind(this, kind), + TRANSITION_DURATION_MS + TRANSITION_BUFFER_MS); + }, + + /** + * This cleans up class and rule added for transition effect and then + * notifies that the style has been applied. + */ + _onTransitionEnd: function (kind) + { + this._transitionTimeout = null; + removePseudoClassLock(this.document.documentElement, TRANSITION_PSEUDO_CLASS); + + let index = this.rawSheet.cssRules.length - 1; + let rule = this.rawSheet.cssRules[index]; + if (rule.selectorText == TRANSITION_RULE_SELECTOR) { + this.rawSheet.deleteRule(index); + } + + events.emit(this, "style-applied", kind, this); + } +}); + +exports.StyleSheetActor = StyleSheetActor; + +/** + * Creates a StyleSheetsActor. StyleSheetsActor provides remote access to the + * stylesheets of a document. + */ +var StyleSheetsActor = protocol.ActorClassWithSpec(styleSheetsSpec, { + /** + * The window we work with, taken from the parent actor. + */ + get window() { + return this.parentActor.window; + }, + + /** + * The current content document of the window we work with. + */ + get document() { + return this.window.document; + }, + + form: function () + { + return { actor: this.actorID }; + }, + + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.parentActor = tabActor; + }, + + /** + * Protocol method for getting a list of StyleSheetActors representing + * all the style sheets in this document. + */ + getStyleSheets: Task.async(function* () { + // Iframe document can change during load (bug 1171919). Track their windows + // instead. + let windows = [this.window]; + let actors = []; + + for (let win of windows) { + let sheets = yield this._addStyleSheets(win); + actors = actors.concat(sheets); + + // Recursively handle style sheets of the documents in iframes. + for (let iframe of win.document.querySelectorAll("iframe, browser, frame")) { + if (iframe.contentDocument && iframe.contentWindow) { + // Sometimes, iframes don't have any document, like the + // one that are over deeply nested (bug 285395) + windows.push(iframe.contentWindow); + } + } + } + return actors; + }), + + /** + * Check if we should be showing this stylesheet. + * + * @param {Document} doc + * Document for which we're checking + * @param {DOMCSSStyleSheet} sheet + * Stylesheet we're interested in + * + * @return boolean + * Whether the stylesheet should be listed. + */ + _shouldListSheet: function (doc, sheet) { + // Special case about:PreferenceStyleSheet, as it is generated on the + // fly and the URI is not registered with the about: handler. + // https://bugzilla.mozilla.org/show_bug.cgi?id=935803#c37 + if (sheet.href && sheet.href.toLowerCase() == "about:preferencestylesheet") { + return false; + } + + return true; + }, + + /** + * Add all the stylesheets for the document in this window to the map and + * create an actor for each one if not already created. + * + * @param {Window} win + * Window for which to add stylesheets + * + * @return {Promise} + * Promise that resolves to an array of StyleSheetActors + */ + _addStyleSheets: function (win) + { + return Task.spawn(function* () { + let doc = win.document; + // readyState can be uninitialized if an iframe has just been created but + // it has not started to load yet. + if (doc.readyState === "loading" || doc.readyState === "uninitialized") { + // Wait for the document to load first. + yield listenOnce(win, "DOMContentLoaded", true); + + // Make sure we have the actual document for this window. If the + // readyState was initially uninitialized, the initial dummy document + // was replaced with the actual document (bug 1171919). + doc = win.document; + } + + let isChrome = Services.scriptSecurityManager.isSystemPrincipal(doc.nodePrincipal); + let styleSheets = isChrome ? DOMUtils.getAllStyleSheets(doc) : doc.styleSheets; + let actors = []; + for (let i = 0; i < styleSheets.length; i++) { + let sheet = styleSheets[i]; + if (!this._shouldListSheet(doc, sheet)) { + continue; + } + + let actor = this.parentActor.createStyleSheetActor(sheet); + actors.push(actor); + + // Get all sheets, including imported ones + let imports = yield this._getImported(doc, actor); + actors = actors.concat(imports); + } + return actors; + }.bind(this)); + }, + + /** + * Get all the stylesheets @imported from a stylesheet. + * + * @param {Document} doc + * The document including the stylesheet + * @param {DOMStyleSheet} styleSheet + * Style sheet to search + * @return {Promise} + * A promise that resolves with an array of StyleSheetActors + */ + _getImported: function (doc, styleSheet) { + return Task.spawn(function* () { + let rules = yield styleSheet.getCSSRules(); + let imported = []; + + for (let i = 0; i < rules.length; i++) { + let rule = rules[i]; + if (rule.type == Ci.nsIDOMCSSRule.IMPORT_RULE) { + // Associated styleSheet may be null if it has already been seen due + // to duplicate @imports for the same URL. + if (!rule.styleSheet || !this._shouldListSheet(doc, rule.styleSheet)) { + continue; + } + let actor = this.parentActor.createStyleSheetActor(rule.styleSheet); + imported.push(actor); + + // recurse imports in this stylesheet as well + let children = yield this._getImported(doc, actor); + imported = imported.concat(children); + } + else if (rule.type != Ci.nsIDOMCSSRule.CHARSET_RULE) { + // @import rules must precede all others except @charset + break; + } + } + + return imported; + }.bind(this)); + }, + + + /** + * Create a new style sheet in the document with the given text. + * Return an actor for it. + * + * @param {object} request + * Debugging protocol request object, with 'text property' + * @return {object} + * Object with 'styelSheet' property for form on new actor. + */ + addStyleSheet: function (text) { + let parent = this.document.documentElement; + let style = this.document.createElementNS("http://www.w3.org/1999/xhtml", "style"); + style.setAttribute("type", "text/css"); + + if (text) { + style.appendChild(this.document.createTextNode(text)); + } + parent.appendChild(style); + + let actor = this.parentActor.createStyleSheetActor(style.sheet); + return actor; + } +}); + +exports.StyleSheetsActor = StyleSheetsActor; + +/** + * Normalize multiple relative paths towards the base paths on the right. + */ +function normalize(...aURLs) { + let base = Services.io.newURI(aURLs.pop(), null, null); + let url; + while ((url = aURLs.pop())) { + base = Services.io.newURI(url, null, base); + } + return base.spec; +} + +function dirname(aPath) { + return Services.io.newURI( + ".", null, Services.io.newURI(aPath, null, null)).spec; +} diff --git a/devtools/server/actors/timeline.js b/devtools/server/actors/timeline.js new file mode 100644 index 000000000..221454144 --- /dev/null +++ b/devtools/server/actors/timeline.js @@ -0,0 +1,98 @@ +/* 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"; + +/** + * Many Gecko operations (painting, reflows, restyle, ...) can be tracked + * in real time. A marker is a representation of one operation. A marker + * has a name, start and end timestamps. Markers are stored in docShells. + * + * This actor exposes this tracking mechanism to the devtools protocol. + * Most of the logic is handled in devtools/server/performance/timeline.js + * This just wraps that module up and exposes it via RDP. + * + * For more documentation: + * @see devtools/server/performance/timeline.js + */ + +const protocol = require("devtools/shared/protocol"); +const { Option, RetVal } = protocol; +const { actorBridgeWithSpec } = require("devtools/server/actors/common"); +const { Timeline } = require("devtools/server/performance/timeline"); +const { timelineSpec } = require("devtools/shared/specs/timeline"); +const events = require("sdk/event/core"); + +/** + * The timeline actor pops and forwards timeline markers registered in docshells. + */ +var TimelineActor = exports.TimelineActor = protocol.ActorClassWithSpec(timelineSpec, { + /** + * Initializes this actor with the provided connection and tab actor. + */ + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this.bridge = new Timeline(tabActor); + + this._onTimelineEvent = this._onTimelineEvent.bind(this); + events.on(this.bridge, "*", this._onTimelineEvent); + }, + + /** + * The timeline actor is the first (and last) in its hierarchy to use + * protocol.js so it doesn't have a parent protocol actor that takes care of + * its lifetime. So it needs a disconnect method to cleanup. + */ + disconnect: function () { + this.destroy(); + }, + + /** + * Destroys this actor, stopping recording first. + */ + destroy: function () { + events.off(this.bridge, "*", this._onTimelineEvent); + this.bridge.destroy(); + this.bridge = null; + this.tabActor = null; + protocol.Actor.prototype.destroy.call(this); + }, + + /** + * Propagate events from the Timeline module over RDP if the event is defined + * here. + */ + _onTimelineEvent: function (eventName, ...args) { + events.emit(this, eventName, ...args); + }, + + isRecording: actorBridgeWithSpec("isRecording", { + request: {}, + response: { + value: RetVal("boolean") + } + }), + + start: actorBridgeWithSpec("start", { + request: { + withMarkers: Option(0, "boolean"), + withTicks: Option(0, "boolean"), + withMemory: Option(0, "boolean"), + withFrames: Option(0, "boolean"), + withGCEvents: Option(0, "boolean"), + withDocLoadingEvents: Option(0, "boolean") + }, + response: { + value: RetVal("number") + } + }), + + stop: actorBridgeWithSpec("stop", { + response: { + // Set as possibly nullable due to the end time possibly being + // undefined during destruction + value: RetVal("nullable:number") + } + }), +}); diff --git a/devtools/server/actors/utils/TabSources.js b/devtools/server/actors/utils/TabSources.js new file mode 100644 index 000000000..56e862939 --- /dev/null +++ b/devtools/server/actors/utils/TabSources.js @@ -0,0 +1,833 @@ +/* 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, Cu } = require("chrome"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { assert, fetch } = DevToolsUtils; +const EventEmitter = require("devtools/shared/event-emitter"); +const { OriginalLocation, GeneratedLocation } = require("devtools/server/actors/common"); +const { resolve } = require("promise"); +const { joinURI } = require("devtools/shared/path"); + +loader.lazyRequireGetter(this, "SourceActor", "devtools/server/actors/source", true); +loader.lazyRequireGetter(this, "isEvalSource", "devtools/server/actors/source", true); +loader.lazyRequireGetter(this, "SourceMapConsumer", "source-map", true); +loader.lazyRequireGetter(this, "SourceMapGenerator", "source-map", true); + +/** + * Manages the sources for a thread. Handles source maps, locations in the + * sources, etc for ThreadActors. + */ +function TabSources(threadActor, allowSourceFn = () => true) { + EventEmitter.decorate(this); + + this._thread = threadActor; + this._useSourceMaps = true; + this._autoBlackBox = true; + this._anonSourceMapId = 1; + this.allowSource = source => { + return !isHiddenSource(source) && allowSourceFn(source); + }; + + this.blackBoxedSources = new Set(); + this.prettyPrintedSources = new Map(); + this.neverAutoBlackBoxSources = new Set(); + + // generated Debugger.Source -> promise of SourceMapConsumer + this._sourceMaps = new Map(); + // sourceMapURL -> promise of SourceMapConsumer + this._sourceMapCache = Object.create(null); + // Debugger.Source -> SourceActor + this._sourceActors = new Map(); + // url -> SourceActor + this._sourceMappedSourceActors = Object.create(null); +} + +/** + * Matches strings of the form "foo.min.js" or "foo-min.js", etc. If the regular + * expression matches, we can be fairly sure that the source is minified, and + * treat it as such. + */ +const MINIFIED_SOURCE_REGEXP = /\bmin\.js$/; + +TabSources.prototype = { + /** + * Update preferences and clear out existing sources + */ + setOptions: function (options) { + let shouldReset = false; + + if ("useSourceMaps" in options) { + shouldReset = true; + this._useSourceMaps = options.useSourceMaps; + } + + if ("autoBlackBox" in options) { + shouldReset = true; + this._autoBlackBox = options.autoBlackBox; + } + + if (shouldReset) { + this.reset(); + } + }, + + /** + * Clear existing sources so they are recreated on the next access. + * + * @param Object opts + * Specify { sourceMaps: true } if you also want to clear + * the source map cache (usually done on reload). + */ + reset: function (opts = {}) { + this._sourceActors = new Map(); + this._sourceMaps = new Map(); + this._sourceMappedSourceActors = Object.create(null); + + if (opts.sourceMaps) { + this._sourceMapCache = Object.create(null); + } + }, + + /** + * Return the source actor representing the `source` (or + * `originalUrl`), creating one if none exists already. May return + * null if the source is disallowed. + * + * @param Debugger.Source source + * The source to make an actor for + * @param String originalUrl + * The original source URL of a sourcemapped source + * @param optional Debguger.Source generatedSource + * The generated source that introduced this source via source map, + * if any. + * @param optional String contentType + * The content type of the source, if immediately available. + * @returns a SourceActor representing the source or null. + */ + source: function ({ source, originalUrl, generatedSource, + isInlineSource, contentType }) { + assert(source || (originalUrl && generatedSource), + "TabSources.prototype.source needs an originalUrl or a source"); + + if (source) { + // If a source is passed, we are creating an actor for a real + // source, which may or may not be sourcemapped. + + if (!this.allowSource(source)) { + return null; + } + + // It's a hack, but inline HTML scripts each have real sources, + // but we want to represent all of them as one source as the + // HTML page. The actor representing this fake HTML source is + // stored in this array, which always has a URL, so check it + // first. + if (source.url in this._sourceMappedSourceActors) { + return this._sourceMappedSourceActors[source.url]; + } + + if (isInlineSource) { + // If it's an inline source, the fake HTML source hasn't been + // created yet (would have returned above), so flip this source + // into a sourcemapped state by giving it an `originalUrl` which + // is the HTML url. + originalUrl = source.url; + source = null; + } + else if (this._sourceActors.has(source)) { + return this._sourceActors.get(source); + } + } + else if (originalUrl) { + // Not all "original" scripts are distinctly separate from the + // generated script. Pretty-printed sources have a sourcemap for + // themselves, so we need to make sure there a real source + // doesn't already exist with this URL. + for (let [source, actor] of this._sourceActors) { + if (source.url === originalUrl) { + return actor; + } + } + + if (originalUrl in this._sourceMappedSourceActors) { + return this._sourceMappedSourceActors[originalUrl]; + } + } + + let actor = new SourceActor({ + thread: this._thread, + source: source, + originalUrl: originalUrl, + generatedSource: generatedSource, + isInlineSource: isInlineSource, + contentType: contentType + }); + + let sourceActorStore = this._thread.sourceActorStore; + var id = sourceActorStore.getReusableActorId(source, originalUrl); + if (id) { + actor.actorID = id; + } + + this._thread.threadLifetimePool.addActor(actor); + sourceActorStore.setReusableActorId(source, originalUrl, actor.actorID); + + if (this._autoBlackBox && + !this.neverAutoBlackBoxSources.has(actor.url) && + this._isMinifiedURL(actor.url)) { + + this.blackBox(actor.url); + this.neverAutoBlackBoxSources.add(actor.url); + } + + if (source) { + this._sourceActors.set(source, actor); + } + else { + this._sourceMappedSourceActors[originalUrl] = actor; + } + + this._emitNewSource(actor); + return actor; + }, + + _emitNewSource: function (actor) { + if (!actor.source) { + // Always notify if we don't have a source because that means + // it's something that has been sourcemapped, or it represents + // the HTML file that contains inline sources. + this.emit("newSource", actor); + } + else { + // If sourcemapping is enabled and a source has sourcemaps, we + // create `SourceActor` instances for both the original and + // generated sources. The source actors for the generated + // sources are only for internal use, however; breakpoints are + // managed by these internal actors. We only want to notify the + // user of the original sources though, so if the actor has a + // `Debugger.Source` instance and a valid source map (meaning + // it's a generated source), don't send the notification. + this.fetchSourceMap(actor.source).then(map => { + if (!map) { + this.emit("newSource", actor); + } + }); + } + }, + + getSourceActor: function (source) { + if (source.url in this._sourceMappedSourceActors) { + return this._sourceMappedSourceActors[source.url]; + } + + if (this._sourceActors.has(source)) { + return this._sourceActors.get(source); + } + + throw new Error("getSource: could not find source actor for " + + (source.url || "source")); + }, + + getSourceActorByURL: function (url) { + if (url) { + for (let [source, actor] of this._sourceActors) { + if (source.url === url) { + return actor; + } + } + + if (url in this._sourceMappedSourceActors) { + return this._sourceMappedSourceActors[url]; + } + } + + throw new Error("getSourceActorByURL: could not find source for " + url); + return null; + }, + + /** + * Returns true if the URL likely points to a minified resource, false + * otherwise. + * + * @param String aURL + * The URL to test. + * @returns Boolean + */ + _isMinifiedURL: function (aURL) { + if (!aURL) { + return false; + } + + try { + let url = new URL(aURL); + let pathname = url.pathname; + return MINIFIED_SOURCE_REGEXP.test(pathname.slice(pathname.lastIndexOf("/") + 1)); + } catch (e) { + // Not a valid URL so don't try to parse out the filename, just test the + // whole thing with the minified source regexp. + return MINIFIED_SOURCE_REGEXP.test(aURL); + } + }, + + /** + * Create a source actor representing this source. This ignores + * source mapping and always returns an actor representing this real + * source. Use `createSourceActors` if you want to respect source maps. + * + * @param Debugger.Source aSource + * The source instance to create an actor for. + * @returns SourceActor + */ + createNonSourceMappedActor: function (aSource) { + // Don't use getSourceURL because we don't want to consider the + // displayURL property if it's an eval source. We only want to + // consider real URLs, otherwise if there is a URL but it's + // invalid the code below will not set the content type, and we + // will later try to fetch the contents of the URL to figure out + // the content type, but it's a made up URL for eval sources. + let url = isEvalSource(aSource) ? null : aSource.url; + let spec = { source: aSource }; + + // XXX bug 915433: We can't rely on Debugger.Source.prototype.text + // if the source is an HTML-embedded <script> tag. Since we don't + // have an API implemented to detect whether this is the case, we + // need to be conservative and only treat valid js files as real + // sources. Otherwise, use the `originalUrl` property to treat it + // as an HTML source that manages multiple inline sources. + + // Assume the source is inline if the element that introduced it is not a + // script element, or does not have a src attribute. + let element = aSource.element ? aSource.element.unsafeDereference() : null; + if (element && (element.tagName !== "SCRIPT" || !element.hasAttribute("src"))) { + spec.isInlineSource = true; + } else if (aSource.introductionType === "wasm") { + // Wasm sources are not JavaScript. Give them their own content-type. + spec.contentType = "text/wasm"; + } else { + if (url) { + // There are a few special URLs that we know are JavaScript: + // inline `javascript:` and code coming from the console + if (url.indexOf("Scratchpad/") === 0 || + url.indexOf("javascript:") === 0 || + url === "debugger eval code") { + spec.contentType = "text/javascript"; + } else { + try { + let pathname = new URL(url).pathname; + let filename = pathname.slice(pathname.lastIndexOf("/") + 1); + let index = filename.lastIndexOf("."); + let extension = index >= 0 ? filename.slice(index + 1) : ""; + if (extension === "xml") { + // XUL inline scripts may not correctly have the + // `source.element` property, so do a blunt check here if + // it's an xml page. + spec.isInlineSource = true; + } + else if (extension === "js") { + spec.contentType = "text/javascript"; + } + } catch (e) { + // This only needs to be here because URL is not yet exposed to + // workers. (BUG 1258892) + const filename = url; + const index = filename.lastIndexOf("."); + const extension = index >= 0 ? filename.slice(index + 1) : ""; + if (extension === "js") { + spec.contentType = "text/javascript"; + } + } + } + } + else { + // Assume the content is javascript if there's no URL + spec.contentType = "text/javascript"; + } + } + + return this.source(spec); + }, + + /** + * This is an internal function that returns a promise of an array + * of source actors representing all the source mapped sources of + * `aSource`, or `null` if the source is not sourcemapped or + * sourcemapping is disabled. Users should call `createSourceActors` + * instead of this. + * + * @param Debugger.Source aSource + * The source instance to create actors for. + * @return Promise of an array of source actors + */ + _createSourceMappedActors: function (aSource) { + if (!this._useSourceMaps || !aSource.sourceMapURL) { + return resolve(null); + } + + return this.fetchSourceMap(aSource) + .then(map => { + if (map) { + return map.sources.map(s => { + return this.source({ originalUrl: s, generatedSource: aSource }); + }).filter(isNotNull); + } + return null; + }); + }, + + /** + * Creates the source actors representing the appropriate sources + * of `aSource`. If sourcemapped, returns actors for all of the original + * sources, otherwise returns a 1-element array with the actor for + * `aSource`. + * + * @param Debugger.Source aSource + * The source instance to create actors for. + * @param Promise of an array of source actors + */ + createSourceActors: function (aSource) { + return this._createSourceMappedActors(aSource).then(actors => { + let actor = this.createNonSourceMappedActor(aSource); + return (actors || [actor]).filter(isNotNull); + }); + }, + + /** + * Return a promise of a SourceMapConsumer for the source map for + * `aSource`; if we already have such a promise extant, return that. + * This will fetch the source map if we don't have a cached object + * and source maps are enabled (see `_fetchSourceMap`). + * + * @param Debugger.Source aSource + * The source instance to get sourcemaps for. + * @return Promise of a SourceMapConsumer + */ + fetchSourceMap: function (aSource) { + if (!this._useSourceMaps) { + return resolve(null); + } + else if (this._sourceMaps.has(aSource)) { + return this._sourceMaps.get(aSource); + } + else if (!aSource || !aSource.sourceMapURL) { + return resolve(null); + } + + let sourceMapURL = aSource.sourceMapURL; + if (aSource.url) { + sourceMapURL = joinURI(aSource.url, sourceMapURL); + } + let result = this._fetchSourceMap(sourceMapURL, aSource.url); + + // The promises in `_sourceMaps` must be the exact same instances + // as returned by `_fetchSourceMap` for `clearSourceMapCache` to + // work. + this._sourceMaps.set(aSource, result); + return result; + }, + + /** + * Return a promise of a SourceMapConsumer for the source map for + * `aSource`. The resolved result may be null if the source does not + * have a source map or source maps are disabled. + */ + getSourceMap: function (aSource) { + return resolve(this._sourceMaps.get(aSource)); + }, + + /** + * Set a SourceMapConsumer for the source map for + * |aSource|. + */ + setSourceMap: function (aSource, aMap) { + this._sourceMaps.set(aSource, resolve(aMap)); + }, + + /** + * Return a promise of a SourceMapConsumer for the source map located at + * |aAbsSourceMapURL|, which must be absolute. If there is already such a + * promise extant, return it. This will not fetch if source maps are + * disabled. + * + * @param string aAbsSourceMapURL + * The source map URL, in absolute form, not relative. + * @param string aScriptURL + * When the source map URL is a data URI, there is no sourceRoot on the + * source map, and the source map's sources are relative, we resolve + * them from aScriptURL. + */ + _fetchSourceMap: function (aAbsSourceMapURL, aSourceURL) { + assert(this._useSourceMaps, + "Cannot fetch sourcemaps if they are disabled"); + + if (this._sourceMapCache[aAbsSourceMapURL]) { + return this._sourceMapCache[aAbsSourceMapURL]; + } + + let fetching = fetch(aAbsSourceMapURL, { loadFromCache: false }) + .then(({ content }) => { + let map = new SourceMapConsumer(content); + this._setSourceMapRoot(map, aAbsSourceMapURL, aSourceURL); + return map; + }) + .then(null, error => { + if (!DevToolsUtils.reportingDisabled) { + DevToolsUtils.reportException("TabSources.prototype._fetchSourceMap", error); + } + return null; + }); + this._sourceMapCache[aAbsSourceMapURL] = fetching; + return fetching; + }, + + /** + * Sets the source map's sourceRoot to be relative to the source map url. + */ + _setSourceMapRoot: function (aSourceMap, aAbsSourceMapURL, aScriptURL) { + // No need to do this fiddling if we won't be fetching any sources over the + // wire. + if (aSourceMap.hasContentsOfAllSources()) { + return; + } + + const base = this._dirname( + aAbsSourceMapURL.indexOf("data:") === 0 + ? aScriptURL + : aAbsSourceMapURL); + aSourceMap.sourceRoot = aSourceMap.sourceRoot + ? joinURI(base, aSourceMap.sourceRoot) + : base; + }, + + _dirname: function (aPath) { + let url = new URL(aPath); + let href = url.href; + return href.slice(0, href.lastIndexOf("/")); + }, + + /** + * Clears the source map cache. Source maps are cached by URL so + * they can be reused across separate Debugger instances (once in + * this cache, they will never be reparsed again). They are + * also cached by Debugger.Source objects for usefulness. By default + * this just removes the Debugger.Source cache, but you can remove + * the lower-level URL cache with the `hard` option. + * + * @param aSourceMapURL string + * The source map URL to uncache + * @param opts object + * An object with the following properties: + * - hard: Also remove the lower-level URL cache, which will + * make us completely forget about the source map. + */ + clearSourceMapCache: function (aSourceMapURL, opts = { hard: false }) { + let oldSm = this._sourceMapCache[aSourceMapURL]; + + if (opts.hard) { + delete this._sourceMapCache[aSourceMapURL]; + } + + if (oldSm) { + // Clear out the current cache so all sources will get the new one + for (let [source, sm] of this._sourceMaps.entries()) { + if (sm === oldSm) { + this._sourceMaps.delete(source); + } + } + } + }, + + /* + * Forcefully change the source map of a source, changing the + * sourceMapURL and installing the source map in the cache. This is + * necessary to expose changes across Debugger instances + * (pretty-printing is the use case). Generate a random url if one + * isn't specified, allowing you to set "anonymous" source maps. + * + * @param aSource Debugger.Source + * The source to change the sourceMapURL property + * @param aUrl string + * The source map URL (optional) + * @param aMap SourceMapConsumer + * The source map instance + */ + setSourceMapHard: function (aSource, aUrl, aMap) { + let url = aUrl; + if (!url) { + // This is a littly hacky, but we want to forcefully set a + // sourcemap regardless of sourcemap settings. We want to + // literally change the sourceMapURL so that all debuggers will + // get this and pretty-printing will Just Work (Debugger.Source + // instances are per-debugger, so we can't key off that). To + // avoid tons of work serializing the sourcemap into a data url, + // just make a fake URL and stick the sourcemap there. + url = "internal://sourcemap" + (this._anonSourceMapId++) + "/"; + } + aSource.sourceMapURL = url; + + // Forcefully set the sourcemap cache. This will be used even if + // sourcemaps are disabled. + this._sourceMapCache[url] = resolve(aMap); + this.emit("updatedSource", this.getSourceActor(aSource)); + }, + + /** + * Return the non-source-mapped location of the given Debugger.Frame. If the + * frame does not have a script, the location's properties are all null. + * + * @param Debugger.Frame aFrame + * The frame whose location we are getting. + * @returns Object + * Returns an object of the form { source, line, column } + */ + getFrameLocation: function (aFrame) { + if (!aFrame || !aFrame.script) { + return new GeneratedLocation(); + } + let {lineNumber, columnNumber} = + aFrame.script.getOffsetLocation(aFrame.offset); + return new GeneratedLocation( + this.createNonSourceMappedActor(aFrame.script.source), + lineNumber, + columnNumber + ); + }, + + /** + * Returns a promise of the location in the original source if the source is + * source mapped, otherwise a promise of the same location. This can + * be called with a source from *any* Debugger instance and we make + * sure to that it works properly, reusing source maps if already + * fetched. Use this from any actor that needs sourcemapping. + */ + getOriginalLocation: function (generatedLocation) { + let { + generatedSourceActor, + generatedLine, + generatedColumn + } = generatedLocation; + let source = generatedSourceActor.source; + let url = source ? source.url : generatedSourceActor._originalUrl; + + // In certain scenarios the source map may have not been fetched + // yet (or at least tied to this Debugger.Source instance), so use + // `fetchSourceMap` instead of `getSourceMap`. This allows this + // function to be called from anywere (across debuggers) and it + // should just automatically work. + return this.fetchSourceMap(source).then(map => { + if (map) { + let { + source: originalUrl, + line: originalLine, + column: originalColumn, + name: originalName + } = map.originalPositionFor({ + line: generatedLine, + column: generatedColumn == null ? Infinity : generatedColumn + }); + + // Since the `Debugger.Source` instance may come from a + // different `Debugger` instance (any actor can call this + // method), we can't rely on any of the source discovery + // setup (`_discoverSources`, etc) to have been run yet. So + // we have to assume that the actor may not already exist, + // and we might need to create it, so use `source` and give + // it the required parameters for a sourcemapped source. + return new OriginalLocation( + originalUrl ? this.source({ + originalUrl: originalUrl, + generatedSource: source + }) : null, + originalLine, + originalColumn, + originalName + ); + } + + // No source map + return OriginalLocation.fromGeneratedLocation(generatedLocation); + }); + }, + + getAllGeneratedLocations: function (originalLocation) { + let { + originalSourceActor, + originalLine, + originalColumn + } = originalLocation; + + let source = (originalSourceActor.source || + originalSourceActor.generatedSource); + + return this.fetchSourceMap(source).then((map) => { + if (map) { + map.computeColumnSpans(); + + return map.allGeneratedPositionsFor({ + source: originalSourceActor.url, + line: originalLine, + column: originalColumn + }).map(({ line, column, lastColumn }) => { + return new GeneratedLocation( + this.createNonSourceMappedActor(source), + line, + column, + lastColumn + ); + }); + } + + return [GeneratedLocation.fromOriginalLocation(originalLocation)]; + }); + }, + + + /** + * Returns a promise of the location in the generated source corresponding to + * the original source and line given. + * + * When we pass a script S representing generated code to `sourceMap`, + * above, that returns a promise P. The process of resolving P populates + * the tables this function uses; thus, it won't know that S's original + * source URLs map to S until P is resolved. + */ + getGeneratedLocation: function (originalLocation) { + let { originalSourceActor } = originalLocation; + + // Both original sources and normal sources could have sourcemaps, + // because normal sources can be pretty-printed which generates a + // sourcemap for itself. Check both of the source properties to make it work + // for both kinds of sources. + let source = originalSourceActor.source || originalSourceActor.generatedSource; + + // See comment about `fetchSourceMap` in `getOriginalLocation`. + return this.fetchSourceMap(source).then((map) => { + if (map) { + let { + originalLine, + originalColumn + } = originalLocation; + + let { + line: generatedLine, + column: generatedColumn + } = map.generatedPositionFor({ + source: originalSourceActor.url, + line: originalLine, + column: originalColumn == null ? 0 : originalColumn, + bias: SourceMapConsumer.LEAST_UPPER_BOUND + }); + + return new GeneratedLocation( + this.createNonSourceMappedActor(source), + generatedLine, + generatedColumn + ); + } + + return GeneratedLocation.fromOriginalLocation(originalLocation); + }); + }, + + /** + * Returns true if URL for the given source is black boxed. + * + * @param aURL String + * The URL of the source which we are checking whether it is black + * boxed or not. + */ + isBlackBoxed: function (aURL) { + return this.blackBoxedSources.has(aURL); + }, + + /** + * Add the given source URL to the set of sources that are black boxed. + * + * @param aURL String + * The URL of the source which we are black boxing. + */ + blackBox: function (aURL) { + this.blackBoxedSources.add(aURL); + }, + + /** + * Remove the given source URL to the set of sources that are black boxed. + * + * @param aURL String + * The URL of the source which we are no longer black boxing. + */ + unblackBox: function (aURL) { + this.blackBoxedSources.delete(aURL); + }, + + /** + * Returns true if the given URL is pretty printed. + * + * @param aURL String + * The URL of the source that might be pretty printed. + */ + isPrettyPrinted: function (aURL) { + return this.prettyPrintedSources.has(aURL); + }, + + /** + * Add the given URL to the set of sources that are pretty printed. + * + * @param aURL String + * The URL of the source to be pretty printed. + */ + prettyPrint: function (aURL, aIndent) { + this.prettyPrintedSources.set(aURL, aIndent); + }, + + /** + * Return the indent the given URL was pretty printed by. + */ + prettyPrintIndent: function (aURL) { + return this.prettyPrintedSources.get(aURL); + }, + + /** + * Remove the given URL from the set of sources that are pretty printed. + * + * @param aURL String + * The URL of the source that is no longer pretty printed. + */ + disablePrettyPrint: function (aURL) { + this.prettyPrintedSources.delete(aURL); + }, + + iter: function () { + let actors = Object.keys(this._sourceMappedSourceActors).map(k => { + return this._sourceMappedSourceActors[k]; + }); + for (let actor of this._sourceActors.values()) { + if (!this._sourceMaps.has(actor.source)) { + actors.push(actor); + } + } + return actors; + } +}; + +/* + * Checks if a source should never be displayed to the user because + * it's either internal or we don't support in the UI yet. + */ +function isHiddenSource(aSource) { + // Ignore the internal Function.prototype script + return aSource.text === "() {\n}"; +} + +/** + * Returns true if its argument is not null. + */ +function isNotNull(aThing) { + return aThing !== null; +} + +exports.TabSources = TabSources; +exports.isHiddenSource = isHiddenSource; diff --git a/devtools/server/actors/utils/actor-registry-utils.js b/devtools/server/actors/utils/actor-registry-utils.js new file mode 100644 index 000000000..5866827e1 --- /dev/null +++ b/devtools/server/actors/utils/actor-registry-utils.js @@ -0,0 +1,78 @@ +/* -*- 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, CC, Ci, Cc } = require("chrome"); + +const { DebuggerServer } = require("devtools/server/main"); +const promise = require("promise"); + +/** + * Support for actor registration. Main used by ActorRegistryActor + * for dynamic registration of new actors. + * + * @param sourceText {String} Source of the actor implementation + * @param fileName {String} URL of the actor module (for proper stack traces) + * @param options {Object} Configuration object + */ +exports.registerActor = function (sourceText, fileName, options) { + // Register in the current process + exports.registerActorInCurrentProcess(sourceText, fileName, options); + // Register in any child processes + return DebuggerServer.setupInChild({ + module: "devtools/server/actors/utils/actor-registry-utils", + setupChild: "registerActorInCurrentProcess", + args: [sourceText, fileName, options], + waitForEval: true + }); +}; + +exports.registerActorInCurrentProcess = function (sourceText, fileName, options) { + const principal = CC("@mozilla.org/systemprincipal;1", "nsIPrincipal")(); + const sandbox = Cu.Sandbox(principal); + sandbox.exports = {}; + sandbox.require = require; + + Cu.evalInSandbox(sourceText, sandbox, "1.8", fileName, 1); + + let { prefix, constructor, type } = options; + + if (type.global && !DebuggerServer.globalActorFactories.hasOwnProperty(prefix)) { + DebuggerServer.addGlobalActor({ + constructorName: constructor, + constructorFun: sandbox[constructor] + }, prefix); + } + + if (type.tab && !DebuggerServer.tabActorFactories.hasOwnProperty(prefix)) { + DebuggerServer.addTabActor({ + constructorName: constructor, + constructorFun: sandbox[constructor] + }, prefix); + } +}; + +exports.unregisterActor = function (options) { + // Unregister in the current process + exports.unregisterActorInCurrentProcess(options); + // Unregister in any child processes + DebuggerServer.setupInChild({ + module: "devtools/server/actors/utils/actor-registry-utils", + setupChild: "unregisterActorInCurrentProcess", + args: [options] + }); +}; + +exports.unregisterActorInCurrentProcess = function (options) { + if (options.tab) { + DebuggerServer.removeTabActor(options); + } + + if (options.global) { + DebuggerServer.removeGlobalActor(options); + } +}; diff --git a/devtools/server/actors/utils/audionodes.json b/devtools/server/actors/utils/audionodes.json new file mode 100644 index 000000000..12cc6c34b --- /dev/null +++ b/devtools/server/actors/utils/audionodes.json @@ -0,0 +1,113 @@ +{ + "OscillatorNode": { + "source": true, + "properties": { + "type": {}, + "frequency": { + "param": true + }, + "detune": { + "param": true + } + } + }, + "GainNode": { + "properties": { "gain": { "param": true }} + }, + "DelayNode": { + "properties": { "delayTime": { "param": true }} + }, + "AudioBufferSourceNode": { + "source": true, + "properties": { + "buffer": { "Buffer": true }, + "playbackRate": { + "param": true + }, + "loop": {}, + "loopStart": {}, + "loopEnd": {} + } + }, + "ScriptProcessorNode": { + "properties": { "bufferSize": { "readonly": true }} + }, + "PannerNode": { + "properties": { + "panningModel": {}, + "distanceModel": {}, + "refDistance": {}, + "maxDistance": {}, + "rolloffFactor": {}, + "coneInnerAngle": {}, + "coneOuterAngle": {}, + "coneOuterGain": {} + } + }, + "ConvolverNode": { + "properties": { + "buffer": { "Buffer": true }, + "normalize": {} + } + }, + "DynamicsCompressorNode": { + "properties": { + "threshold": { "param": true }, + "knee": { "param": true }, + "ratio": { "param": true }, + "reduction": {}, + "attack": { "param": true }, + "release": { "param": true } + } + }, + "BiquadFilterNode": { + "properties": { + "type": {}, + "frequency": { "param": true }, + "Q": { "param": true }, + "detune": { "param": true }, + "gain": { "param": true } + } + }, + "WaveShaperNode": { + "properties": { + "curve": { "Float32Array": true }, + "oversample": {} + } + }, + "AnalyserNode": { + "properties": { + "fftSize": {}, + "minDecibels": {}, + "maxDecibels": {}, + "smoothingTimeConstant": {}, + "frequencyBinCount": { "readonly": true } + } + }, + "AudioDestinationNode": { + "unbypassable": true + }, + "ChannelSplitterNode": { + "unbypassable": true + }, + "ChannelMergerNode": { + "unbypassable": true + }, + "MediaElementAudioSourceNode": { + "source": true + }, + "MediaStreamAudioSourceNode": { + "source": true + }, + "MediaStreamAudioDestinationNode": { + "unbypassable": true, + "properties": { + "stream": { "MediaStream": true } + } + }, + "StereoPannerNode": { + "properties": { + "pan": { "param": true } + } + } +} diff --git a/devtools/server/actors/utils/automation-timeline.js b/devtools/server/actors/utils/automation-timeline.js new file mode 100644 index 000000000..a086d90be --- /dev/null +++ b/devtools/server/actors/utils/automation-timeline.js @@ -0,0 +1,373 @@ +/** + * web-audio-automation-timeline - 1.0.3 + * https://github.com/jsantell/web-audio-automation-timeline + * MIT License, copyright (c) 2014 Jordan Santell + */ +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.Timeline=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ +module.exports = require("./lib/timeline").Timeline; + +},{"./lib/timeline":4}],2:[function(require,module,exports){ +var F = require("./formulas"); + +function TimelineEvent (eventName, value, time, timeConstant, duration) { + this.type = eventName; + this.value = value; + this.time = time; + this.constant = timeConstant || 0; + this.duration = duration || 0; +} +exports.TimelineEvent = TimelineEvent; + + +TimelineEvent.prototype.exponentialApproach = function (lastValue, time) { + return F.exponentialApproach(this.time, lastValue, this.value, this.constant, time); +} + +TimelineEvent.prototype.extractValueFromCurve = function (time) { + return F.extractValueFromCurve(this.time, this.value, this.value.length, this.duration, time); +} + +TimelineEvent.prototype.linearInterpolate = function (next, time) { + return F.linearInterpolate(this.time, this.value, next.time, next.value, time); +} + +TimelineEvent.prototype.exponentialInterpolate = function (next, time) { + return F.exponentialInterpolate(this.time, this.value, next.time, next.value, time); +} + +},{"./formulas":3}],3:[function(require,module,exports){ +var EPSILON = 0.0000000001; + +exports.linearInterpolate = function (t0, v0, t1, v1, t) { + return v0 + (v1 - v0) * ((t - t0) / (t1 - t0)); +}; + +exports.exponentialInterpolate = function (t0, v0, t1, v1, t) { + return v0 * Math.pow(v1 / v0, (t - t0) / (t1 - t0)); +}; + +exports.extractValueFromCurve = function (start, curve, curveLength, duration, t) { + var ratio; + + // If time is after duration, return the last curve value, + // or if ratio is >= 1 + if (t >= start + duration || (ratio = Math.max((t - start) / duration, 0)) >= 1) { + return curve[curveLength - 1]; + } + + return curve[~~(curveLength * ratio)]; +}; + +exports.exponentialApproach = function (t0, v0, v1, timeConstant, t) { + return v1 + (v0 - v1) * Math.exp(-(t - t0) / timeConstant); +}; + +// Since we are going to accumulate error by adding 0.01 multiple times +// in a loop, we want to fuzz the equality check in `getValueAtTime` +exports.fuzzyEqual = function (lhs, rhs) { + return Math.abs(lhs - rhs) < EPSILON; +}; + +exports.EPSILON = EPSILON; + +},{}],4:[function(require,module,exports){ +var TimelineEvent = require("./event").TimelineEvent; +var F = require("./formulas"); + +exports.Timeline = Timeline; + +function Timeline (defaultValue) { + this.events = []; + + this._value = defaultValue || 0; +} + +Timeline.prototype.getEventCount = function () { + return this.events.length; +}; + +Timeline.prototype.value = function () { + return this._value; +}; + +Timeline.prototype.setValue = function (value) { + if (this.events.length === 0) { + this._value = value; + } +}; + +Timeline.prototype.getValue = function () { + if (this.events.length) { + throw new Error("Can only call `getValue` when there are 0 events."); + } + + return this._value; +}; + +Timeline.prototype.getValueAtTime = function (time) { + return this._getValueAtTimeHelper(time); +}; + +Timeline.prototype._getValueAtTimeHelper = function (time) { + var bailOut = false; + var previous = null; + var next = null; + var lastComputedValue = null; // Used for `setTargetAtTime` nodes + var events = this.events; + var e; + + for (var i = 0; !bailOut && i < events.length; i++) { + if (F.fuzzyEqual(time, events[i].time)) { + // Find the last event with the same time as `time` + do { + ++i; + } while (i < events.length && F.fuzzyEqual(time, events[i].time)); + + e = events[i - 1]; + + // `setTargetAtTime` can be handled no matter what their next event is (if they have one) + if (e.type === "setTargetAtTime") { + lastComputedValue = this._lastComputedValue(e); + return e.exponentialApproach(lastComputedValue, time); + } + + // `setValueCurveAtTime` events can be handled no matter what their next event node is + // (if they have one) + if (e.type === "setValueCurveAtTime") { + return e.extractValueFromCurve(time); + } + + // For other event types + return e.value; + } + previous = next; + next = events[i]; + + if (time < events[i].time) { + bailOut = true; + } + } + + // Handle the case where the time is past all of the events + if (!bailOut) { + previous = next; + next = null; + } + + // Just return the default value if we did not find anything + if (!previous && !next) { + return this._value; + } + + // If the requested time is before all of the existing events + if (!previous) { + return this._value; + } + + // `setTargetAtTime` can be handled no matter what their next event is (if they have one) + if (previous.type === "setTargetAtTime") { + lastComputedValue = this._lastComputedValue(previous); + return previous.exponentialApproach(lastComputedValue, time); + } + + // `setValueCurveAtTime` events can be handled no matter what their next event node is + // (if they have one) + if (previous.type === "setValueCurveAtTime") { + return previous.extractValueFromCurve(time); + } + + if (!next) { + if (~["setValueAtTime", "linearRampToValueAtTime", "exponentialRampToValueAtTime"].indexOf(previous.type)) { + return previous.value; + } + if (previous.type === "setValueCurveAtTime") { + return previous.extractValueFromCurve(time); + } + if (previous.type === "setTargetAtTime") { + throw new Error("unreached"); + } + throw new Error("unreached"); + } + + // Finally handle the case where we have both a previous and a next event + // First handle the case where our range ends up in a ramp event + if (next.type === "linearRampToValueAtTime") { + return previous.linearInterpolate(next, time); + } else if (next.type === "exponentialRampToValueAtTime") { + return previous.exponentialInterpolate(next, time); + } + + // Now handle all other cases + if (~["setValueAtTime", "linearRampToValueAtTime", "exponentialRampToValueAtTime"].indexOf(previous.type)) { + // If the next event type is neither linear or exponential ramp, + // the value is constant. + return previous.value; + } + if (previous.type === "setValueCurveAtTime") { + return previous.extractValueFromCurve(time); + } + if (previous.type === "setTargetAtTime") { + throw new Error("unreached"); + } + throw new Error("unreached"); +}; + +Timeline.prototype._insertEvent = function (ev) { + var events = this.events; + + if (ev.type === "setValueCurveAtTime") { + if (!ev.value || !ev.value.length) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + + if (ev.type === "setTargetAtTime") { + if (F.fuzzyEqual(ev.constant, 0)) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + + // Make sure that non-curve events don't fall within the duration of a + // curve event. + for (var i = 0; i < events.length; i++) { + if (events[i].type === "setValueCurveAtTime" && + events[i].time <= ev.time && + (events[i].time + events[i].duration) >= ev.time) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + + // Make sure that curve events don't fall in a range which includes other + // events. + if (ev.type === "setValueCurveAtTime") { + for (var i = 0; i < events.length; i++) { + if (events[i].time > ev.time && + events[i].time < (ev.time + ev.duration)) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + } + + // Make sure that invalid values are not used for exponential curves + if (ev.type === "exponentialRampToValueAtTime") { + if (ev.value <= 0) throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + var prev = this._getPreviousEvent(ev.time); + if (prev) { + if (prev.value <= 0) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } else { + if (this._value <= 0) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + } + + for (var i = 0; i < events.length; i++) { + if (ev.time === events[i].time) { + if (ev.type === events[i].type) { + // If times and types are equal, replace the event; + events[i] = ev; + } else { + // Otherwise, place the element after the last event of another type + do { i++; } + while (i < events.length && ev.type !== events[i].type && ev.time === events[i].time); + events.splice(i, 0, ev); + } + return; + } + // Otherwise, place the event right after the latest existing event + if (ev.time < events[i].time) { + events.splice(i, 0, ev); + return; + } + } + + // If we couldn't find a place for the event, just append it to the list + this.events.push(ev); +}; + +Timeline.prototype._getPreviousEvent = function (time) { + var previous = null, next = null; + var bailOut = false; + var events = this.events; + + for (var i = 0; !bailOut && i < events.length; i++) { + if (time === events[i]) { + do { ++i; } + while (i < events.length && time === events[i].time); + return events[i - 1]; + } + previous = next; + next = events[i]; + if (time < events[i].time) { + bailOut = true; + } + } + + // Handle the case where the time is past all the events + if (!bailOut) { + previous = next; + } + + return previous; +}; + +/** + * Calculates the previous value of the timeline, used for + * `setTargetAtTime` nodes. Takes an event, and returns + * the previous computed value for any sample taken during that + * exponential approach node. + */ +Timeline.prototype._lastComputedValue = function (event) { + // If equal times, return the value for the previous event, before + // the `setTargetAtTime` node. + var lastEvent = this._getPreviousEvent(event.time - F.EPSILON); + + // If no event before the setTargetAtTime event, then return the + // intrinsic value. + if (!lastEvent) { + return this._value; + } + // Otherwise, return the value for the previous event, which should + // always be the last computed value (? I think?) + else { + return lastEvent.value; + } +}; + +Timeline.prototype.setValueAtTime = function (value, startTime) { + this._insertEvent(new TimelineEvent("setValueAtTime", value, startTime)); +}; + +Timeline.prototype.linearRampToValueAtTime = function (value, endTime) { + this._insertEvent(new TimelineEvent("linearRampToValueAtTime", value, endTime)); +}; + +Timeline.prototype.exponentialRampToValueAtTime = function (value, endTime) { + this._insertEvent(new TimelineEvent("exponentialRampToValueAtTime", value, endTime)); +}; + +Timeline.prototype.setTargetAtTime = function (value, startTime, timeConstant) { + this._insertEvent(new TimelineEvent("setTargetAtTime", value, startTime, timeConstant)); +}; + +Timeline.prototype.setValueCurveAtTime = function (value, startTime, duration) { + this._insertEvent(new TimelineEvent("setValueCurveAtTime", value, startTime, null, duration)); +}; + +Timeline.prototype.cancelScheduledValues = function (time) { + for (var i = 0; i < this.events.length; i++) { + if (this.events[i].time >= time) { + this.events = this.events.slice(0, i); + break; + } + } +}; + +Timeline.prototype.cancelAllEvents = function () { + this.events.length = 0; +}; + +},{"./event":2,"./formulas":3}]},{},[1])(1) +});
\ No newline at end of file diff --git a/devtools/server/actors/utils/css-grid-utils.js b/devtools/server/actors/utils/css-grid-utils.js new file mode 100644 index 000000000..0b260117d --- /dev/null +++ b/devtools/server/actors/utils/css-grid-utils.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Cu } = require("chrome"); + +/** + * Returns the grid fragment array with all the grid fragment data stringifiable. + * + * @param {Object} fragments + * Grid fragment object. + * @return {Array} representation with the grid fragment data stringifiable. + */ +function getStringifiableFragments(fragments = []) { + if (fragments[0] && Cu.isDeadWrapper(fragments[0])) { + return {}; + } + + return fragments.map(getStringifiableFragment); +} + +/** + * Returns a string representation of the CSS Grid data as returned by + * node.getGridFragments. This is useful to compare grid state at each update and redraw + * the highlighter if needed. It also seralizes the grid fragment data so it can be used + * by protocol.js. + * + * @param {Object} fragments + * Grid fragment object. + * @return {String} representation of the CSS grid fragment data. + */ +function stringifyGridFragments(fragments) { + return JSON.stringify(getStringifiableFragments(fragments)); +} + +function getStringifiableFragment(fragment) { + return { + cols: getStringifiableDimension(fragment.cols), + rows: getStringifiableDimension(fragment.rows) + }; +} + +function getStringifiableDimension(dimension) { + return { + lines: [...dimension.lines].map(getStringifiableLine), + tracks: [...dimension.tracks].map(getStringifiableTrack), + }; +} + +function getStringifiableLine({ breadth, number, start, names }) { + return { breadth, number, start, names }; +} + +function getStringifiableTrack({ breadth, start, state, type }) { + return { breadth, start, state, type }; +} + +exports.getStringifiableFragments = getStringifiableFragments; +exports.stringifyGridFragments = stringifyGridFragments; diff --git a/devtools/server/actors/utils/make-debugger.js b/devtools/server/actors/utils/make-debugger.js new file mode 100644 index 000000000..9bd43e567 --- /dev/null +++ b/devtools/server/actors/utils/make-debugger.js @@ -0,0 +1,101 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; 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 Debugger = require("Debugger"); + +const { reportException } = require("devtools/shared/DevToolsUtils"); + +/** + * Multiple actors that use a |Debugger| instance come in a few versions, each + * with a different set of debuggees. One version for content tabs (globals + * within a tab), one version for chrome debugging (all globals), and sometimes + * a third version for addon debugging (chrome globals the addon is loaded in + * and content globals the addon injects scripts into). The |makeDebugger| + * function helps us avoid repeating the logic for finding and maintaining the + * correct set of globals for a given |Debugger| instance across each version of + * all of our actors. + * + * The |makeDebugger| function expects a single object parameter with the + * following properties: + * + * @param Function findDebuggees + * Called with one argument: a |Debugger| instance. This function should + * return an iterable of globals to be added to the |Debugger| + * instance. The globals may be wrapped in a |Debugger.Object|, or + * unwrapped. + * + * @param Function shouldAddNewGlobalAsDebuggee + * Called with one argument: a |Debugger.Object| wrapping a global + * object. This function must return |true| if the global object should + * be added as debuggee, and |false| otherwise. + * + * @returns Debugger + * Returns a |Debugger| instance that can manage its set of debuggee + * globals itself and is decorated with the |EventEmitter| class. + * + * Events emitted by the returned |Debugger| instance: + * + * - "newGlobal": Emitted when a new global has been added as a + * debuggee. Passes the |Debugger.Object| wrapping the new + * debuggee global to listeners. + * + * Existing |Debugger| properties set on the returned |Debugger| + * instance: + * + * - onNewGlobalObject: The |Debugger| will automatically add new + * globals as debuggees if calling |shouldAddNewGlobalAsDebuggee| + * with the global returns true. + * + * - uncaughtExceptionHook: The |Debugger| already has an error + * reporter attached to |uncaughtExceptionHook|, so if any + * |Debugger| hooks fail, the error will be reported. + * + * New properties set on the returned |Debugger| instance: + * + * - addDebuggees: A function which takes no arguments. It adds all + * current globals that should be debuggees (as determined by + * |findDebuggees|) to the |Debugger| instance. + */ +module.exports = function makeDebugger({ findDebuggees, shouldAddNewGlobalAsDebuggee }) { + const dbg = new Debugger(); + EventEmitter.decorate(dbg); + + dbg.allowUnobservedAsmJS = true; + dbg.uncaughtExceptionHook = reportDebuggerHookException; + + dbg.onNewGlobalObject = function (global) { + if (shouldAddNewGlobalAsDebuggee(global)) { + safeAddDebuggee(this, global); + } + }; + + dbg.addDebuggees = function () { + for (let global of findDebuggees(this)) { + safeAddDebuggee(this, global); + } + }; + + return dbg; +}; + +const reportDebuggerHookException = e => reportException("Debugger Hook", e); + +/** + * Add |global| as a debuggee to |dbg|, handling error cases. + */ +function safeAddDebuggee(dbg, global) { + try { + let wrappedGlobal = dbg.addDebuggee(global); + if (wrappedGlobal) { + dbg.emit("newGlobal", wrappedGlobal); + } + } catch (e) { + // Ignoring attempt to add the debugger's compartment as a debuggee. + } +} diff --git a/devtools/server/actors/utils/map-uri-to-addon-id.js b/devtools/server/actors/utils/map-uri-to-addon-id.js new file mode 100644 index 000000000..6f3316b14 --- /dev/null +++ b/devtools/server/actors/utils/map-uri-to-addon-id.js @@ -0,0 +1,44 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; 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 DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const Services = require("Services"); + +loader.lazyServiceGetter(this, "AddonPathService", + "@mozilla.org/addon-path-service;1", + "amIAddonPathService"); + +const B2G_ID = "{3c2e2abc-06d4-11e1-ac3b-374f68613e61}"; +const GRAPHENE_ID = "{d1bfe7d9-c01e-4237-998b-7b5f960a4314}"; + +/** + * This is a wrapper around amIAddonPathService.mapURIToAddonID which always returns + * false on B2G and graphene to avoid loading the add-on manager there and + * reports any exceptions rather than throwing so that the caller doesn't have + * to worry about them. + */ +if (!Services.appinfo + || Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT + || Services.appinfo.ID === undefined /* XPCShell */ + || Services.appinfo.ID == B2G_ID + || Services.appinfo.ID == GRAPHENE_ID + || !AddonPathService) { + module.exports = function mapURIToAddonId(uri) { + return false; + }; +} else { + module.exports = function mapURIToAddonId(uri) { + try { + return AddonPathService.mapURIToAddonId(uri); + } + catch (e) { + DevToolsUtils.reportException("mapURIToAddonId", e); + return false; + } + }; +} diff --git a/devtools/server/actors/utils/moz.build b/devtools/server/actors/utils/moz.build new file mode 100644 index 000000000..0dcf40faf --- /dev/null +++ b/devtools/server/actors/utils/moz.build @@ -0,0 +1,19 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + 'actor-registry-utils.js', + 'audionodes.json', + 'automation-timeline.js', + 'css-grid-utils.js', + 'make-debugger.js', + 'map-uri-to-addon-id.js', + 'stack.js', + 'TabSources.js', + 'walker-search.js', + 'webconsole-utils.js', + 'webconsole-worker-utils.js', +) diff --git a/devtools/server/actors/utils/stack.js b/devtools/server/actors/utils/stack.js new file mode 100644 index 000000000..a6a3d1137 --- /dev/null +++ b/devtools/server/actors/utils/stack.js @@ -0,0 +1,185 @@ +/* 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 {Class} = require("sdk/core/heritage"); + +/** + * A helper class that stores stack frame objects. Each frame is + * assigned an index, and if a frame is added more than once, the same + * index is used. Users of the class can get an array of all frames + * that have been added. + */ +var StackFrameCache = Class({ + /** + * Initialize this object. + */ + initialize: function () { + this._framesToIndices = null; + this._framesToForms = null; + this._lastEventSize = 0; + }, + + /** + * Prepare to accept frames. + */ + initFrames: function () { + if (this._framesToIndices) { + // The maps are already initialized. + return; + } + + this._framesToIndices = new Map(); + this._framesToForms = new Map(); + this._lastEventSize = 0; + }, + + /** + * Forget all stored frames and reset to the initialized state. + */ + clearFrames: function () { + this._framesToIndices.clear(); + this._framesToIndices = null; + this._framesToForms.clear(); + this._framesToForms = null; + this._lastEventSize = 0; + }, + + /** + * Add a frame to this stack frame cache, and return the index of + * the frame. + */ + addFrame: function (frame) { + this._assignFrameIndices(frame); + this._createFrameForms(frame); + return this._framesToIndices.get(frame); + }, + + /** + * A helper method for the memory actor. This populates the packet + * object with "frames" property. Each of these + * properties will be an array indexed by frame ID. "frames" will + * contain frame objects (see makeEvent). + * + * @param packet + * The packet to update. + * + * @returns packet + */ + updateFramePacket: function (packet) { + // Now that we are guaranteed to have a form for every frame, we know the + // size the "frames" property's array must be. We use that information to + // create dense arrays even though we populate them out of order. + const size = this._framesToForms.size; + packet.frames = Array(size).fill(null); + + // Populate the "frames" properties. + for (let [stack, index] of this._framesToIndices) { + packet.frames[index] = this._framesToForms.get(stack); + } + + return packet; + }, + + /** + * If any new stack frames have been added to this cache since the + * last call to makeEvent (clearing the cache also resets the "last + * call"), then return a new array describing the new frames. If no + * new frames are available, return null. + * + * The frame cache assumes that the user of the cache keeps track of + * all previously-returned arrays and, in theory, concatenates them + * all to form a single array holding all frames added to the cache + * since the last reset. This concatenated array can be indexed by + * the frame ID. The array returned by this function, though, is + * dense and starts at 0. + * + * Each element in the array is an object of the form: + * { + * line: <line number for this frame>, + * column: <column number for this frame>, + * source: <filename string for this frame>, + * functionDisplayName: <this frame's inferred function name function or null>, + * parent: <frame ID -- an index into the concatenated array mentioned above> + * asyncCause: the async cause, or null + * asyncParent: <frame ID -- an index into the concatenated array mentioned above> + * } + * + * The intent of this approach is to make it simpler to efficiently + * send frame information over the debugging protocol, by only + * sending new frames. + * + * @returns array or null + */ + makeEvent: function () { + const size = this._framesToForms.size; + if (!size || size <= this._lastEventSize) { + return null; + } + + let packet = Array(size - this._lastEventSize).fill(null); + for (let [stack, index] of this._framesToIndices) { + if (index >= this._lastEventSize) { + packet[index - this._lastEventSize] = this._framesToForms.get(stack); + } + } + + this._lastEventSize = size; + + return packet; + }, + + /** + * Assigns an index to the given frame and its parents, if an index is not + * already assigned. + * + * @param SavedFrame frame + * A frame to assign an index to. + */ + _assignFrameIndices: function (frame) { + if (this._framesToIndices.has(frame)) { + return; + } + + if (frame) { + this._assignFrameIndices(frame.parent); + this._assignFrameIndices(frame.asyncParent); + } + + const index = this._framesToIndices.size; + this._framesToIndices.set(frame, index); + }, + + /** + * Create the form for the given frame, if one doesn't already exist. + * + * @param SavedFrame frame + * A frame to create a form for. + */ + _createFrameForms: function (frame) { + if (this._framesToForms.has(frame)) { + return; + } + + let form = null; + if (frame) { + form = { + line: frame.line, + column: frame.column, + source: frame.source, + functionDisplayName: frame.functionDisplayName, + parent: this._framesToIndices.get(frame.parent), + asyncParent: this._framesToIndices.get(frame.asyncParent), + asyncCause: frame.asyncCause + }; + this._createFrameForms(frame.parent); + this._createFrameForms(frame.asyncParent); + } + + this._framesToForms.set(frame, form); + }, +}); + +exports.StackFrameCache = StackFrameCache; diff --git a/devtools/server/actors/utils/walker-search.js b/devtools/server/actors/utils/walker-search.js new file mode 100644 index 000000000..0955a3919 --- /dev/null +++ b/devtools/server/actors/utils/walker-search.js @@ -0,0 +1,278 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * The walker-search module provides a simple API to index and search strings + * and elements inside a given document. + * It indexes tag names, attribute names and values, and text contents. + * It provides a simple search function that returns a list of nodes that + * matched. + */ + +const {Ci, Cu} = require("chrome"); + +/** + * The WalkerIndex class indexes the document (and all subdocs) from + * a given walker. + * + * It is only indexed the first time the data is accessed and will be + * re-indexed if a mutation happens between requests. + * + * @param {Walker} walker The walker to be indexed + */ +function WalkerIndex(walker) { + this.walker = walker; + this.clearIndex = this.clearIndex.bind(this); + + // Kill the index when mutations occur, the next data get will re-index. + this.walker.on("any-mutation", this.clearIndex); +} + +WalkerIndex.prototype = { + /** + * Destroy this instance, releasing all data and references + */ + destroy: function () { + this.walker.off("any-mutation", this.clearIndex); + }, + + clearIndex: function () { + if (!this.currentlyIndexing) { + this._data = null; + } + }, + + get doc() { + return this.walker.rootDoc; + }, + + /** + * Get the indexed data + * This getter also indexes if it hasn't been done yet or if the state is + * dirty + * + * @returns Map<String, Array<{type:String, node:DOMNode}>> + * A Map keyed on the searchable value, containing an array with + * objects containing the 'type' (one of ALL_RESULTS_TYPES), and + * the DOM Node. + */ + get data() { + if (!this._data) { + this._data = new Map(); + this.index(); + } + + return this._data; + }, + + _addToIndex: function (type, node, value) { + // Add an entry for this value if there isn't one + let entry = this._data.get(value); + if (!entry) { + this._data.set(value, []); + } + + // Add the type/node to the list + this._data.get(value).push({ + type: type, + node: node + }); + }, + + index: function () { + // Handle case where iterating nextNode() with the deepTreeWalker triggers + // a mutation (Bug 1222558) + this.currentlyIndexing = true; + + let documentWalker = this.walker.getDocumentWalker(this.doc); + while (documentWalker.nextNode()) { + let node = documentWalker.currentNode; + + if (node.nodeType === 1) { + // For each element node, we get the tagname and all attributes names + // and values + let localName = node.localName; + if (localName === "_moz_generated_content_before") { + this._addToIndex("tag", node, "::before"); + this._addToIndex("text", node, node.textContent.trim()); + } else if (localName === "_moz_generated_content_after") { + this._addToIndex("tag", node, "::after"); + this._addToIndex("text", node, node.textContent.trim()); + } else { + this._addToIndex("tag", node, node.localName); + } + + for (let {name, value} of node.attributes) { + this._addToIndex("attributeName", node, name); + this._addToIndex("attributeValue", node, value); + } + } else if (node.textContent && node.textContent.trim().length) { + // For comments and text nodes, we get the text + this._addToIndex("text", node, node.textContent.trim()); + } + } + + this.currentlyIndexing = false; + } +}; + +exports.WalkerIndex = WalkerIndex; + +/** + * The WalkerSearch class provides a way to search an indexed document as well + * as find elements that match a given css selector. + * + * Usage example: + * let s = new WalkerSearch(doc); + * let res = s.search("lang", index); + * for (let {matched, results} of res) { + * for (let {node, type} of results) { + * console.log("The query matched a node's " + type); + * console.log("Node that matched", node); + * } + * } + * s.destroy(); + * + * @param {Walker} the walker to be searched + */ +function WalkerSearch(walker) { + this.walker = walker; + this.index = new WalkerIndex(this.walker); +} + +WalkerSearch.prototype = { + destroy: function () { + this.index.destroy(); + this.walker = null; + }, + + _addResult: function (node, type, results) { + if (!results.has(node)) { + results.set(node, []); + } + + let matches = results.get(node); + + // Do not add if the exact same result is already in the list + let isKnown = false; + for (let match of matches) { + if (match.type === type) { + isKnown = true; + break; + } + } + + if (!isKnown) { + matches.push({type}); + } + }, + + _searchIndex: function (query, options, results) { + for (let [matched, res] of this.index.data) { + if (!options.searchMethod(query, matched)) { + continue; + } + + // Add any relevant results (skipping non-requested options). + res.filter(entry => { + return options.types.indexOf(entry.type) !== -1; + }).forEach(({node, type}) => { + this._addResult(node, type, results); + }); + } + }, + + _searchSelectors: function (query, options, results) { + // If the query is just one "word", no need to search because _searchIndex + // will lead the same results since it has access to tagnames anyway + let isSelector = query && query.match(/[ >~.#\[\]]/); + if (options.types.indexOf("selector") === -1 || !isSelector) { + return; + } + + let nodes = this.walker._multiFrameQuerySelectorAll(query); + for (let node of nodes) { + this._addResult(node, "selector", results); + } + }, + + /** + * Search the document + * @param {String} query What to search for + * @param {Object} options The following options are accepted: + * - searchMethod {String} one of WalkerSearch.SEARCH_METHOD_* + * defaults to WalkerSearch.SEARCH_METHOD_CONTAINS (does not apply to + * selector search type) + * - types {Array} a list of things to search for (tag, text, attributes, etc) + * defaults to WalkerSearch.ALL_RESULTS_TYPES + * @return {Array} An array is returned with each item being an object like: + * { + * node: <the dom node that matched>, + * type: <the type of match: one of WalkerSearch.ALL_RESULTS_TYPES> + * } + */ + search: function (query, options = {}) { + options.searchMethod = options.searchMethod || WalkerSearch.SEARCH_METHOD_CONTAINS; + options.types = options.types || WalkerSearch.ALL_RESULTS_TYPES; + + // Empty strings will return no results, as will non-string input + if (typeof query !== "string") { + query = ""; + } + + // Store results in a map indexed by nodes to avoid duplicate results + let results = new Map(); + + // Search through the indexed data + this._searchIndex(query, options, results); + + // Search with querySelectorAll + this._searchSelectors(query, options, results); + + // Concatenate all results into an Array to return + let resultList = []; + for (let [node, matches] of results) { + for (let {type} of matches) { + resultList.push({ + node: node, + type: type, + }); + + // For now, just do one result per node since the frontend + // doesn't have a way to highlight each result individually + // yet. + break; + } + } + + let documents = this.walker.tabActor.windows.map(win=>win.document); + + // Sort the resulting nodes by order of appearance in the DOM + resultList.sort((a, b) => { + // Disconnected nodes won't get good results from compareDocumentPosition + // so check the order of their document instead. + if (a.node.ownerDocument != b.node.ownerDocument) { + let indA = documents.indexOf(a.node.ownerDocument); + let indB = documents.indexOf(b.node.ownerDocument); + return indA - indB; + } + // If the same document, then sort on DOCUMENT_POSITION_FOLLOWING (4) + // which means B is after A. + return a.node.compareDocumentPosition(b.node) & 4 ? -1 : 1; + }); + + return resultList; + } +}; + +WalkerSearch.SEARCH_METHOD_CONTAINS = (query, candidate) => { + return query && candidate.toLowerCase().indexOf(query.toLowerCase()) !== -1; +}; + +WalkerSearch.ALL_RESULTS_TYPES = ["tag", "text", "attributeName", + "attributeValue", "selector"]; + +exports.WalkerSearch = WalkerSearch; diff --git a/devtools/server/actors/utils/webconsole-utils.js b/devtools/server/actors/utils/webconsole-utils.js new file mode 100644 index 000000000..597f1ddb3 --- /dev/null +++ b/devtools/server/actors/utils/webconsole-utils.js @@ -0,0 +1,1063 @@ +/* -*- 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 {Cc, Ci, Cu, components} = require("chrome"); +const {isWindowIncluded} = require("devtools/shared/layout/utils"); +const Services = require("Services"); +const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); + +// TODO: Bug 842672 - browser/ imports modules from toolkit/. +// Note that these are only used in WebConsoleCommands, see $0 and pprint(). +loader.lazyImporter(this, "VariablesView", "resource://devtools/client/shared/widgets/VariablesView.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, + "swm", + "@mozilla.org/serviceworkers/manager;1", + "nsIServiceWorkerManager"); + +const CONSOLE_WORKER_IDS = exports.CONSOLE_WORKER_IDS = [ + "SharedWorker", + "ServiceWorker", + "Worker" +]; + +var WebConsoleUtils = { + + /** + * Given a message, return one of CONSOLE_WORKER_IDS if it matches + * one of those. + * + * @return string + */ + getWorkerType: function (message) { + let id = message ? message.innerID : null; + return CONSOLE_WORKER_IDS[CONSOLE_WORKER_IDS.indexOf(id)] || null; + }, + + /** + * Clone an object. + * + * @param object object + * The object you want cloned. + * @param boolean recursive + * Tells if you want to dig deeper into the object, to clone + * recursively. + * @param function [filter] + * Optional, filter function, called for every property. Three + * arguments are passed: key, value and object. Return true if the + * property should be added to the cloned object. Return false to skip + * the property. + * @return object + * The cloned object. + */ + cloneObject: function (object, recursive, filter) { + if (typeof object != "object") { + return object; + } + + let temp; + + if (Array.isArray(object)) { + temp = []; + Array.forEach(object, function (value, index) { + if (!filter || filter(index, value, object)) { + temp.push(recursive ? WebConsoleUtils.cloneObject(value) : value); + } + }); + } else { + temp = {}; + for (let key in object) { + let value = object[key]; + if (object.hasOwnProperty(key) && + (!filter || filter(key, value, object))) { + temp[key] = recursive ? WebConsoleUtils.cloneObject(value) : value; + } + } + } + + return temp; + }, + + /** + * Gets the ID of the inner window of this DOM window. + * + * @param nsIDOMWindow window + * @return integer + * Inner ID for the given window. + */ + getInnerWindowId: function (window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID; + }, + + /** + * Recursively gather a list of inner window ids given a + * top level window. + * + * @param nsIDOMWindow window + * @return Array + * list of inner window ids. + */ + getInnerWindowIDsForFrames: function (window) { + let innerWindowID = this.getInnerWindowId(window); + let ids = [innerWindowID]; + + if (window.frames) { + for (let i = 0; i < window.frames.length; i++) { + let frame = window.frames[i]; + ids = ids.concat(this.getInnerWindowIDsForFrames(frame)); + } + } + + return ids; + }, + + /** + * Get the property descriptor for the given object. + * + * @param object object + * The object that contains the property. + * @param string prop + * The property you want to get the descriptor for. + * @return object + * Property descriptor. + */ + getPropertyDescriptor: function (object, prop) { + let desc = null; + while (object) { + try { + if ((desc = Object.getOwnPropertyDescriptor(object, prop))) { + break; + } + } catch (ex) { + // Native getters throw here. See bug 520882. + // null throws TypeError. + if (ex.name != "NS_ERROR_XPC_BAD_CONVERT_JS" && + ex.name != "NS_ERROR_XPC_BAD_OP_ON_WN_PROTO" && + ex.name != "TypeError") { + throw ex; + } + } + + try { + object = Object.getPrototypeOf(object); + } catch (ex) { + if (ex.name == "TypeError") { + return desc; + } + throw ex; + } + } + return desc; + }, + + /** + * Create a grip for the given value. If the value is an object, + * an object wrapper will be created. + * + * @param mixed value + * The value you want to create a grip for, before sending it to the + * client. + * @param function objectWrapper + * If the value is an object then the objectWrapper function is + * invoked to give us an object grip. See this.getObjectGrip(). + * @return mixed + * The value grip. + */ + createValueGrip: function (value, objectWrapper) { + switch (typeof value) { + case "boolean": + return value; + case "string": + return objectWrapper(value); + case "number": + if (value === Infinity) { + return { type: "Infinity" }; + } else if (value === -Infinity) { + return { type: "-Infinity" }; + } else if (Number.isNaN(value)) { + return { type: "NaN" }; + } else if (!value && 1 / value === -Infinity) { + return { type: "-0" }; + } + return value; + case "undefined": + return { type: "undefined" }; + case "object": + if (value === null) { + return { type: "null" }; + } + // Fall through. + case "function": + return objectWrapper(value); + default: + console.error("Failed to provide a grip for value of " + typeof value + + ": " + value); + return null; + } + }, +}; + +exports.Utils = WebConsoleUtils; + +// The page errors listener + +/** + * The nsIConsoleService listener. This is used to send all of the console + * messages (JavaScript, CSS and more) to the remote Web Console instance. + * + * @constructor + * @param nsIDOMWindow [window] + * Optional - the window object for which we are created. This is used + * for filtering out messages that belong to other windows. + * @param object listener + * The listener object must have one method: + * - onConsoleServiceMessage(). This method is invoked with one argument, + * the nsIConsoleMessage, whenever a relevant message is received. + */ +function ConsoleServiceListener(window, listener) { + this.window = window; + this.listener = listener; +} +exports.ConsoleServiceListener = ConsoleServiceListener; + +ConsoleServiceListener.prototype = +{ + QueryInterface: XPCOMUtils.generateQI([Ci.nsIConsoleListener]), + + /** + * The content window for which we listen to page errors. + * @type nsIDOMWindow + */ + window: null, + + /** + * The listener object which is notified of messages from the console service. + * @type object + */ + listener: null, + + /** + * Initialize the nsIConsoleService listener. + */ + init: function () { + Services.console.registerListener(this); + }, + + /** + * The nsIConsoleService observer. This method takes all the script error + * messages belonging to the current window and sends them to the remote Web + * Console instance. + * + * @param nsIConsoleMessage message + * The message object coming from the nsIConsoleService. + */ + observe: function (message) { + if (!this.listener) { + return; + } + + if (this.window) { + if (!(message instanceof Ci.nsIScriptError) || + !message.outerWindowID || + !this.isCategoryAllowed(message.category)) { + return; + } + + let errorWindow = Services.wm.getOuterWindowWithId(message.outerWindowID); + if (!errorWindow || !isWindowIncluded(this.window, errorWindow)) { + return; + } + } + + this.listener.onConsoleServiceMessage(message); + }, + + /** + * Check if the given message category is allowed to be tracked or not. + * We ignore chrome-originating errors as we only care about content. + * + * @param string category + * The message category you want to check. + * @return boolean + * True if the category is allowed to be logged, false otherwise. + */ + isCategoryAllowed: function (category) { + if (!category) { + return false; + } + + switch (category) { + case "XPConnect JavaScript": + case "component javascript": + case "chrome javascript": + case "chrome registration": + case "XBL": + case "XBL Prototype Handler": + case "XBL Content Sink": + case "xbl javascript": + return false; + } + + return true; + }, + + /** + * Get the cached page errors for the current inner window and its (i)frames. + * + * @param boolean [includePrivate=false] + * Tells if you want to also retrieve messages coming from private + * windows. Defaults to false. + * @return array + * The array of cached messages. Each element is an nsIScriptError or + * an nsIConsoleMessage + */ + getCachedMessages: function (includePrivate = false) { + let errors = Services.console.getMessageArray() || []; + + // if !this.window, we're in a browser console. Still need to filter + // private messages. + if (!this.window) { + return errors.filter((error) => { + if (error instanceof Ci.nsIScriptError) { + if (!includePrivate && error.isFromPrivateWindow) { + return false; + } + } + + return true; + }); + } + + let ids = WebConsoleUtils.getInnerWindowIDsForFrames(this.window); + + return errors.filter((error) => { + if (error instanceof Ci.nsIScriptError) { + if (!includePrivate && error.isFromPrivateWindow) { + return false; + } + if (ids && + (ids.indexOf(error.innerWindowID) == -1 || + !this.isCategoryAllowed(error.category))) { + return false; + } + } else if (ids && ids[0]) { + // If this is not an nsIScriptError and we need to do window-based + // filtering we skip this message. + return false; + } + + return true; + }); + }, + + /** + * Remove the nsIConsoleService listener. + */ + destroy: function () { + Services.console.unregisterListener(this); + this.listener = this.window = null; + }, +}; + +// The window.console API observer + +/** + * The window.console API observer. This allows the window.console API messages + * to be sent to the remote Web Console instance. + * + * @constructor + * @param nsIDOMWindow window + * Optional - the window object for which we are created. This is used + * for filtering out messages that belong to other windows. + * @param object owner + * The owner object must have the following methods: + * - onConsoleAPICall(). This method is invoked with one argument, the + * Console API message that comes from the observer service, whenever + * a relevant console API call is received. + * @param object filteringOptions + * Optional - The filteringOptions that this listener should listen to: + * - addonId: filter console messages based on the addonId. + */ +function ConsoleAPIListener(window, owner, {addonId} = {}) { + this.window = window; + this.owner = owner; + this.addonId = addonId; +} +exports.ConsoleAPIListener = ConsoleAPIListener; + +ConsoleAPIListener.prototype = +{ + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), + + /** + * The content window for which we listen to window.console API calls. + * @type nsIDOMWindow + */ + window: null, + + /** + * The owner object which is notified of window.console API calls. It must + * have a onConsoleAPICall method which is invoked with one argument: the + * console API call object that comes from the observer service. + * + * @type object + * @see WebConsoleActor + */ + owner: null, + + /** + * The addonId that we listen for. If not null then only messages from this + * console will be returned. + */ + addonId: null, + + /** + * Initialize the window.console API observer. + */ + init: function () { + // Note that the observer is process-wide. We will filter the messages as + // needed, see CAL_observe(). + Services.obs.addObserver(this, "console-api-log-event", false); + }, + + /** + * The console API message observer. When messages are received from the + * observer service we forward them to the remote Web Console instance. + * + * @param object message + * The message object receives from the observer service. + * @param string topic + * The message topic received from the observer service. + */ + observe: function (message, topic) { + if (!this.owner) { + return; + } + + // Here, wrappedJSObject is not a security wrapper but a property defined + // by the XPCOM component which allows us to unwrap the XPCOM interface and + // access the underlying JSObject. + let apiMessage = message.wrappedJSObject; + + if (!this.isMessageRelevant(apiMessage)) { + return; + } + + this.owner.onConsoleAPICall(apiMessage); + }, + + /** + * Given a message, return true if this window should show it and false + * if it should be ignored. + * + * @param message + * The message from the Storage Service + * @return bool + * Do we care about this message? + */ + isMessageRelevant: function (message) { + let workerType = WebConsoleUtils.getWorkerType(message); + + if (this.window && workerType === "ServiceWorker") { + // For messages from Service Workers, message.ID is the + // scope, which can be used to determine whether it's controlling + // a window. + let scope = message.ID; + + if (!swm.shouldReportToWindow(this.window, scope)) { + return false; + } + } + + if (this.window && !workerType) { + let msgWindow = Services.wm.getCurrentInnerWindowWithId(message.innerID); + if (!msgWindow || !isWindowIncluded(this.window, msgWindow)) { + // Not the same window! + return false; + } + } + + if (this.addonId) { + // ConsoleAPI.jsm messages contains a consoleID, (and it is currently + // used in Addon SDK add-ons), the standard 'console' object + // (which is used in regular webpages and in WebExtensions pages) + // contains the originAttributes of the source document principal. + + // Filtering based on the originAttributes used by + // the Console API object. + if (message.originAttributes && + message.originAttributes.addonId == this.addonId) { + return true; + } + + // Filtering based on the old-style consoleID property used by + // the legacy Console JSM module. + if (message.consoleID && message.consoleID == `addon/${this.addonId}`) { + return true; + } + + return false; + } + + return true; + }, + + /** + * Get the cached messages for the current inner window and its (i)frames. + * + * @param boolean [includePrivate=false] + * Tells if you want to also retrieve messages coming from private + * windows. Defaults to false. + * @return array + * The array of cached messages. + */ + getCachedMessages: function (includePrivate = false) { + let messages = []; + let ConsoleAPIStorage = Cc["@mozilla.org/consoleAPI-storage;1"] + .getService(Ci.nsIConsoleAPIStorage); + + // if !this.window, we're in a browser console. Retrieve all events + // for filtering based on privacy. + if (!this.window) { + messages = ConsoleAPIStorage.getEvents(); + } else { + let ids = WebConsoleUtils.getInnerWindowIDsForFrames(this.window); + ids.forEach((id) => { + messages = messages.concat(ConsoleAPIStorage.getEvents(id)); + }); + } + + CONSOLE_WORKER_IDS.forEach((id) => { + messages = messages.concat(ConsoleAPIStorage.getEvents(id)); + }); + + messages = messages.filter(msg => { + return this.isMessageRelevant(msg); + }); + + if (includePrivate) { + return messages; + } + + return messages.filter((m) => !m.private); + }, + + /** + * Destroy the console API listener. + */ + destroy: function () { + Services.obs.removeObserver(this, "console-api-log-event"); + this.window = this.owner = null; + }, +}; + +/** + * WebConsole commands manager. + * + * Defines a set of functions /variables ("commands") that are available from + * the Web Console but not from the web page. + * + */ +var WebConsoleCommands = { + _registeredCommands: new Map(), + _originalCommands: new Map(), + + /** + * @private + * Reserved for built-in commands. To register a command from the code of an + * add-on, see WebConsoleCommands.register instead. + * + * @see WebConsoleCommands.register + */ + _registerOriginal: function (name, command) { + this.register(name, command); + this._originalCommands.set(name, this.getCommand(name)); + }, + + /** + * Register a new command. + * @param {string} name The command name (exemple: "$") + * @param {(function|object)} command The command to register. + * It can be a function so the command is a function (like "$()"), + * or it can also be a property descriptor to describe a getter / value (like + * "$0"). + * + * The command function or the command getter are passed a owner object as + * their first parameter (see the example below). + * + * Note that setters don't work currently and "enumerable" and "configurable" + * are forced to true. + * + * @example + * + * WebConsoleCommands.register("$", function JSTH_$(owner, selector) + * { + * return owner.window.document.querySelector(selector); + * }); + * + * WebConsoleCommands.register("$0", { + * get: function(owner) { + * return owner.makeDebuggeeValue(owner.selectedNode); + * } + * }); + */ + register: function (name, command) { + this._registeredCommands.set(name, command); + }, + + /** + * Unregister a command. + * + * If the command being unregister overrode a built-in command, + * the latter is restored. + * + * @param {string} name The name of the command + */ + unregister: function (name) { + this._registeredCommands.delete(name); + if (this._originalCommands.has(name)) { + this.register(name, this._originalCommands.get(name)); + } + }, + + /** + * Returns a command by its name. + * + * @param {string} name The name of the command. + * + * @return {(function|object)} The command. + */ + getCommand: function (name) { + return this._registeredCommands.get(name); + }, + + /** + * Returns true if a command is registered with the given name. + * + * @param {string} name The name of the command. + * + * @return {boolean} True if the command is registered. + */ + hasCommand: function (name) { + return this._registeredCommands.has(name); + }, +}; + +exports.WebConsoleCommands = WebConsoleCommands; + +/* + * Built-in commands. + * + * A list of helper functions used by Firebug can be found here: + * http://getfirebug.com/wiki/index.php/Command_Line_API + */ + +/** + * Find a node by ID. + * + * @param string id + * The ID of the element you want. + * @return nsIDOMNode or null + * The result of calling document.querySelector(selector). + */ +WebConsoleCommands._registerOriginal("$", function (owner, selector) { + return owner.window.document.querySelector(selector); +}); + +/** + * Find the nodes matching a CSS selector. + * + * @param string selector + * A string that is passed to window.document.querySelectorAll. + * @return nsIDOMNodeList + * Returns the result of document.querySelectorAll(selector). + */ +WebConsoleCommands._registerOriginal("$$", function (owner, selector) { + let nodes = owner.window.document.querySelectorAll(selector); + + // Calling owner.window.Array.from() doesn't work without accessing the + // wrappedJSObject, so just loop through the results instead. + let result = new owner.window.Array(); + for (let i = 0; i < nodes.length; i++) { + result.push(nodes[i]); + } + return result; +}); + +/** + * Returns the result of the last console input evaluation + * + * @return object|undefined + * Returns last console evaluation or undefined + */ +WebConsoleCommands._registerOriginal("$_", { + get: function (owner) { + return owner.consoleActor.getLastConsoleInputEvaluation(); + } +}); + +/** + * Runs an xPath query and returns all matched nodes. + * + * @param string xPath + * xPath search query to execute. + * @param [optional] nsIDOMNode context + * Context to run the xPath query on. Uses window.document if not set. + * @return array of nsIDOMNode + */ +WebConsoleCommands._registerOriginal("$x", function (owner, xPath, context) { + let nodes = new owner.window.Array(); + + // Not waiving Xrays, since we want the original Document.evaluate function, + // instead of anything that's been redefined. + let doc = owner.window.document; + context = context || doc; + + let results = doc.evaluate(xPath, context, null, + Ci.nsIDOMXPathResult.ANY_TYPE, null); + let node; + while ((node = results.iterateNext())) { + nodes.push(node); + } + + return nodes; +}); + +/** + * Returns the currently selected object in the highlighter. + * + * @return Object representing the current selection in the + * Inspector, or null if no selection exists. + */ +WebConsoleCommands._registerOriginal("$0", { + get: function (owner) { + return owner.makeDebuggeeValue(owner.selectedNode); + } +}); + +/** + * Clears the output of the WebConsole. + */ +WebConsoleCommands._registerOriginal("clear", function (owner) { + owner.helperResult = { + type: "clearOutput", + }; +}); + +/** + * Clears the input history of the WebConsole. + */ +WebConsoleCommands._registerOriginal("clearHistory", function (owner) { + owner.helperResult = { + type: "clearHistory", + }; +}); + +/** + * Returns the result of Object.keys(object). + * + * @param object object + * Object to return the property names from. + * @return array of strings + */ +WebConsoleCommands._registerOriginal("keys", function (owner, object) { + // Need to waive Xrays so we can iterate functions and accessor properties + return Cu.cloneInto(Object.keys(Cu.waiveXrays(object)), owner.window); +}); + +/** + * Returns the values of all properties on object. + * + * @param object object + * Object to display the values from. + * @return array of string + */ +WebConsoleCommands._registerOriginal("values", function (owner, object) { + let values = []; + // Need to waive Xrays so we can iterate functions and accessor properties + let waived = Cu.waiveXrays(object); + let names = Object.getOwnPropertyNames(waived); + + for (let name of names) { + values.push(waived[name]); + } + + return Cu.cloneInto(values, owner.window); +}); + +/** + * Opens a help window in MDN. + */ +WebConsoleCommands._registerOriginal("help", function (owner) { + owner.helperResult = { type: "help" }; +}); + +/** + * Change the JS evaluation scope. + * + * @param DOMElement|string|window window + * The window object to use for eval scope. This can be a string that + * is used to perform document.querySelector(), to find the iframe that + * you want to cd() to. A DOMElement can be given as well, the + * .contentWindow property is used. Lastly, you can directly pass + * a window object. If you call cd() with no arguments, the current + * eval scope is cleared back to its default (the top window). + */ +WebConsoleCommands._registerOriginal("cd", function (owner, window) { + if (!window) { + owner.consoleActor.evalWindow = null; + owner.helperResult = { type: "cd" }; + return; + } + + if (typeof window == "string") { + window = owner.window.document.querySelector(window); + } + if (window instanceof Ci.nsIDOMElement && window.contentWindow) { + window = window.contentWindow; + } + if (!(window instanceof Ci.nsIDOMWindow)) { + owner.helperResult = { + type: "error", + message: "cdFunctionInvalidArgument" + }; + return; + } + + owner.consoleActor.evalWindow = window; + owner.helperResult = { type: "cd" }; +}); + +/** + * Inspects the passed object. This is done by opening the PropertyPanel. + * + * @param object object + * Object to inspect. + */ +WebConsoleCommands._registerOriginal("inspect", function (owner, object) { + let dbgObj = owner.makeDebuggeeValue(object); + let grip = owner.createValueGrip(dbgObj); + owner.helperResult = { + type: "inspectObject", + input: owner.evalInput, + object: grip, + }; +}); + +/** + * Prints object to the output. + * + * @param object object + * Object to print to the output. + * @return string + */ +WebConsoleCommands._registerOriginal("pprint", function (owner, object) { + if (object === null || object === undefined || object === true || + object === false) { + owner.helperResult = { + type: "error", + message: "helperFuncUnsupportedTypeError", + }; + return null; + } + + owner.helperResult = { rawOutput: true }; + + if (typeof object == "function") { + return object + "\n"; + } + + let output = []; + + let obj = object; + for (let name in obj) { + let desc = WebConsoleUtils.getPropertyDescriptor(obj, name) || {}; + if (desc.get || desc.set) { + // TODO: Bug 842672 - toolkit/ imports modules from browser/. + let getGrip = VariablesView.getGrip(desc.get); + let setGrip = VariablesView.getGrip(desc.set); + let getString = VariablesView.getString(getGrip); + let setString = VariablesView.getString(setGrip); + output.push(name + ":", " get: " + getString, " set: " + setString); + } else { + let valueGrip = VariablesView.getGrip(obj[name]); + let valueString = VariablesView.getString(valueGrip); + output.push(name + ": " + valueString); + } + } + + return " " + output.join("\n "); +}); + +/** + * Print the String representation of a value to the output, as-is. + * + * @param any value + * A value you want to output as a string. + * @return void + */ +WebConsoleCommands._registerOriginal("print", function (owner, value) { + owner.helperResult = { rawOutput: true }; + if (typeof value === "symbol") { + return Symbol.prototype.toString.call(value); + } + // Waiving Xrays here allows us to see a closer representation of the + // underlying object. This may execute arbitrary content code, but that + // code will run with content privileges, and the result will be rendered + // inert by coercing it to a String. + return String(Cu.waiveXrays(value)); +}); + +/** + * Copy the String representation of a value to the clipboard. + * + * @param any value + * A value you want to copy as a string. + * @return void + */ +WebConsoleCommands._registerOriginal("copy", function (owner, value) { + let payload; + try { + if (value instanceof Ci.nsIDOMElement) { + payload = value.outerHTML; + } else if (typeof value == "string") { + payload = value; + } else { + payload = JSON.stringify(value, null, " "); + } + } catch (ex) { + payload = "/* " + ex + " */"; + } + owner.helperResult = { + type: "copyValueToClipboard", + value: payload, + }; +}); + +/** + * (Internal only) Add the bindings to |owner.sandbox|. + * This is intended to be used by the WebConsole actor only. + * + * @param object owner + * The owning object. + */ +function addWebConsoleCommands(owner) { + if (!owner) { + throw new Error("The owner is required"); + } + for (let [name, command] of WebConsoleCommands._registeredCommands) { + if (typeof command === "function") { + owner.sandbox[name] = command.bind(undefined, owner); + } else if (typeof command === "object") { + let clone = Object.assign({}, command, { + // We force the enumerability and the configurability (so the + // WebConsoleActor can reconfigure the property). + enumerable: true, + configurable: true + }); + + if (typeof command.get === "function") { + clone.get = command.get.bind(undefined, owner); + } + if (typeof command.set === "function") { + clone.set = command.set.bind(undefined, owner); + } + + Object.defineProperty(owner.sandbox, name, clone); + } + } +} + +exports.addWebConsoleCommands = addWebConsoleCommands; + +/** + * A ReflowObserver that listens for reflow events from the page. + * Implements nsIReflowObserver. + * + * @constructor + * @param object window + * The window for which we need to track reflow. + * @param object owner + * The listener owner which needs to implement: + * - onReflowActivity(reflowInfo) + */ + +function ConsoleReflowListener(window, listener) { + this.docshell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + this.listener = listener; + this.docshell.addWeakReflowObserver(this); +} + +exports.ConsoleReflowListener = ConsoleReflowListener; + +ConsoleReflowListener.prototype = +{ + QueryInterface: XPCOMUtils.generateQI([Ci.nsIReflowObserver, + Ci.nsISupportsWeakReference]), + docshell: null, + listener: null, + + /** + * Forward reflow event to listener. + * + * @param DOMHighResTimeStamp start + * @param DOMHighResTimeStamp end + * @param boolean interruptible + */ + sendReflow: function (start, end, interruptible) { + let frame = components.stack.caller.caller; + + let filename = frame ? frame.filename : null; + + if (filename) { + // Because filename could be of the form "xxx.js -> xxx.js -> xxx.js", + // we only take the last part. + filename = filename.split(" ").pop(); + } + + this.listener.onReflowActivity({ + interruptible: interruptible, + start: start, + end: end, + sourceURL: filename, + sourceLine: frame ? frame.lineNumber : null, + functionName: frame ? frame.name : null + }); + }, + + /** + * On uninterruptible reflow + * + * @param DOMHighResTimeStamp start + * @param DOMHighResTimeStamp end + */ + reflow: function (start, end) { + this.sendReflow(start, end, false); + }, + + /** + * On interruptible reflow + * + * @param DOMHighResTimeStamp start + * @param DOMHighResTimeStamp end + */ + reflowInterruptible: function (start, end) { + this.sendReflow(start, end, true); + }, + + /** + * Unregister listener. + */ + destroy: function () { + this.docshell.removeWeakReflowObserver(this); + this.listener = this.docshell = null; + }, +}; diff --git a/devtools/server/actors/utils/webconsole-worker-utils.js b/devtools/server/actors/utils/webconsole-worker-utils.js new file mode 100644 index 000000000..0c1142967 --- /dev/null +++ b/devtools/server/actors/utils/webconsole-worker-utils.js @@ -0,0 +1,20 @@ +/* -*- 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"; + +// XXXworkers This file is loaded on the server side for worker debugging. +// Since the server is running in the worker thread, it doesn't +// have access to Services / Components. This functionality +// is stubbed out to prevent errors, and will need to implemented +// for Bug 1209353. + +exports.Utils = { L10n: function () {} }; +exports.ConsoleServiceListener = function () {}; +exports.ConsoleAPIListener = function () {}; +exports.addWebConsoleCommands = function () {}; +exports.ConsoleReflowListener = function () {}; +exports.CONSOLE_WORKER_IDS = []; diff --git a/devtools/server/actors/webaudio.js b/devtools/server/actors/webaudio.js new file mode 100644 index 000000000..d9035a907 --- /dev/null +++ b/devtools/server/actors/webaudio.js @@ -0,0 +1,856 @@ +/* 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 {Cc, Ci, Cu, Cr} = require("chrome"); + +const Services = require("Services"); + +const events = require("sdk/event/core"); +const promise = require("promise"); +const { on: systemOn, off: systemOff } = require("sdk/system/events"); +const protocol = require("devtools/shared/protocol"); +const { CallWatcherActor } = require("devtools/server/actors/call-watcher"); +const { CallWatcherFront } = require("devtools/shared/fronts/call-watcher"); +const { createValueGrip } = require("devtools/server/actors/object"); +const AutomationTimeline = require("./utils/automation-timeline"); +const { on, once, off, emit } = events; +const { types, method, Arg, Option, RetVal, preEvent } = protocol; +const { + audionodeSpec, + webAudioSpec, + AUTOMATION_METHODS, + NODE_CREATION_METHODS, + NODE_ROUTING_METHODS, +} = require("devtools/shared/specs/webaudio"); +const { WebAudioFront } = require("devtools/shared/fronts/webaudio"); +const AUDIO_NODE_DEFINITION = require("devtools/server/actors/utils/audionodes.json"); +const ENABLE_AUTOMATION = false; +const AUTOMATION_GRANULARITY = 2000; +const AUTOMATION_GRANULARITY_MAX = 6000; + +const AUDIO_GLOBALS = [ + "AudioContext", "AudioNode", "AudioParam" +]; + +/** + * An Audio Node actor allowing communication to a specific audio node in the + * Audio Context graph. + */ +var AudioNodeActor = exports.AudioNodeActor = protocol.ActorClassWithSpec(audionodeSpec, { + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + return { + actor: this.actorID, // actorID is set when this is added to a pool + type: this.type, + source: this.source, + bypassable: this.bypassable, + }; + }, + + /** + * Create the Audio Node actor. + * + * @param DebuggerServerConnection conn + * The server connection. + * @param AudioNode node + * The AudioNode that was created. + */ + initialize: function (conn, node) { + protocol.Actor.prototype.initialize.call(this, conn); + + // Store ChromeOnly property `id` to identify AudioNode, + // rather than storing a strong reference, and store a weak + // ref to underlying node for controlling. + this.nativeID = node.id; + this.node = Cu.getWeakReference(node); + + // Stores the AutomationTimelines for this node's AudioParams. + this.automation = {}; + + try { + this.type = getConstructorName(node); + } catch (e) { + this.type = ""; + } + + this.source = !!AUDIO_NODE_DEFINITION[this.type].source; + this.bypassable = !AUDIO_NODE_DEFINITION[this.type].unbypassable; + + // Create automation timelines for all AudioParams + Object.keys(AUDIO_NODE_DEFINITION[this.type].properties || {}) + .filter(isAudioParam.bind(null, node)) + .forEach(paramName => { + this.automation[paramName] = new AutomationTimeline(node[paramName].defaultValue); + }); + }, + + /** + * Returns the string name of the audio type. + * + * DEPRECATED: Use `audionode.type` instead, left here for legacy reasons. + */ + getType: function () { + return this.type; + }, + + /** + * Returns a boolean indicating if the AudioNode has been "bypassed", + * via `AudioNodeActor#bypass` method. + * + * @return Boolean + */ + isBypassed: function () { + let node = this.node.get(); + if (node === null) { + return false; + } + + // Cast to boolean incase `passThrough` is undefined, + // like for AudioDestinationNode + return !!node.passThrough; + }, + + /** + * Takes a boolean, either enabling or disabling the "passThrough" option + * on an AudioNode. If a node is bypassed, an effects processing node (like gain, biquad), + * will allow the audio stream to pass through the node, unaffected. Returns + * the bypass state of the node. + * + * @param Boolean enable + * Whether the bypass value should be set on or off. + * @return Boolean + */ + bypass: function (enable) { + let node = this.node.get(); + + if (node === null) { + return; + } + + if (this.bypassable) { + node.passThrough = enable; + } + + return this.isBypassed(); + }, + + /** + * Changes a param on the audio node. Responds with either `undefined` + * on success, or a description of the error upon param set failure. + * + * @param String param + * Name of the AudioParam to change. + * @param String value + * Value to change AudioParam to. + */ + setParam: function (param, value) { + let node = this.node.get(); + + if (node === null) { + return CollectedAudioNodeError(); + } + + try { + if (isAudioParam(node, param)) { + node[param].value = value; + this.automation[param].setValue(value); + } + else { + node[param] = value; + } + return undefined; + } catch (e) { + return constructError(e); + } + }, + + /** + * Gets a param on the audio node. + * + * @param String param + * Name of the AudioParam to fetch. + */ + getParam: function (param) { + let node = this.node.get(); + + if (node === null) { + return CollectedAudioNodeError(); + } + + // Check to see if it's an AudioParam -- if so, + // return the `value` property of the parameter. + let value = isAudioParam(node, param) ? node[param].value : node[param]; + + // Return the grip form of the value; at this time, + // there shouldn't be any non-primitives at the moment, other than + // AudioBuffer or Float32Array references and the like, + // so this just formats the value to be displayed in the VariablesView, + // without using real grips and managing via actor pools. + let grip = createValueGrip(value, null, createObjectGrip); + + return grip; + }, + + /** + * Get an object containing key-value pairs of additional attributes + * to be consumed by a front end, like if a property should be read only, + * or is a special type (Float32Array, Buffer, etc.) + * + * @param String param + * Name of the AudioParam whose flags are desired. + */ + getParamFlags: function (param) { + return ((AUDIO_NODE_DEFINITION[this.type] || {}).properties || {})[param]; + }, + + /** + * Get an array of objects each containing a `param` and `value` property, + * corresponding to a property name and current value of the audio node. + */ + getParams: function (param) { + let props = Object.keys(AUDIO_NODE_DEFINITION[this.type].properties || {}); + return props.map(prop => + ({ param: prop, value: this.getParam(prop), flags: this.getParamFlags(prop) })); + }, + + /** + * Connects this audionode to an AudioParam via `node.connect(param)`. + */ + connectParam: function (destActor, paramName, output) { + let srcNode = this.node.get(); + let destNode = destActor.node.get(); + + if (srcNode === null || destNode === null) { + return CollectedAudioNodeError(); + } + + try { + // Connect via the unwrapped node, so we can call the + // patched method that fires the webaudio actor's `connect-param` event. + // Connect directly to the wrapped `destNode`, otherwise + // the patched method thinks this is a new node and won't be + // able to find it in `_nativeToActorID`. + XPCNativeWrapper.unwrap(srcNode).connect(destNode[paramName], output); + } catch (e) { + return constructError(e); + } + }, + + /** + * Connects this audionode to another via `node.connect(dest)`. + */ + connectNode: function (destActor, output, input) { + let srcNode = this.node.get(); + let destNode = destActor.node.get(); + + if (srcNode === null || destNode === null) { + return CollectedAudioNodeError(); + } + + try { + // Connect via the unwrapped node, so we can call the + // patched method that fires the webaudio actor's `connect-node` event. + // Connect directly to the wrapped `destNode`, otherwise + // the patched method thinks this is a new node and won't be + // able to find it in `_nativeToActorID`. + XPCNativeWrapper.unwrap(srcNode).connect(destNode, output, input); + } catch (e) { + return constructError(e); + } + }, + + /** + * Disconnects this audionode from all connections via `node.disconnect()`. + */ + disconnect: function (destActor, output) { + let node = this.node.get(); + + if (node === null) { + return CollectedAudioNodeError(); + } + + try { + // Disconnect via the unwrapped node, so we can call the + // patched method that fires the webaudio actor's `disconnect` event. + XPCNativeWrapper.unwrap(node).disconnect(output); + } catch (e) { + return constructError(e); + } + }, + + getAutomationData: function (paramName) { + let timeline = this.automation[paramName]; + if (!timeline) { + return null; + } + + let events = timeline.events; + let values = []; + let i = 0; + + if (!timeline.events.length) { + return { events, values }; + } + + let firstEvent = events[0]; + let lastEvent = events[timeline.events.length - 1]; + // `setValueCurveAtTime` will have a duration value -- other + // events will have duration of `0`. + let timeDelta = (lastEvent.time + lastEvent.duration) - firstEvent.time; + let scale = timeDelta / AUTOMATION_GRANULARITY; + + for (; i < AUTOMATION_GRANULARITY; i++) { + let delta = firstEvent.time + (i * scale); + let value = timeline.getValueAtTime(delta); + values.push({ delta, value }); + } + + // If the last event is setTargetAtTime, the automation + // doesn't actually begin until the event's time, and exponentially + // approaches the target value. In this case, we add more values + // until we're "close enough" to the target. + if (lastEvent.type === "setTargetAtTime") { + for (; i < AUTOMATION_GRANULARITY_MAX; i++) { + let delta = firstEvent.time + (++i * scale); + let value = timeline.getValueAtTime(delta); + values.push({ delta, value }); + } + } + + return { events, values }; + }, + + /** + * Called via WebAudioActor, registers an automation event + * for the AudioParam called. + * + * @param String paramName + * Name of the AudioParam. + * @param String eventName + * Name of the automation event called. + * @param Array args + * Arguments passed into the automation call. + */ + addAutomationEvent: function (paramName, eventName, args = []) { + let node = this.node.get(); + let timeline = this.automation[paramName]; + + if (node === null) { + return CollectedAudioNodeError(); + } + + if (!timeline || !node[paramName][eventName]) { + return InvalidCommandError(); + } + + try { + // Using the unwrapped node and parameter, the corresponding + // WebAudioActor event will be fired, subsequently calling + // `_recordAutomationEvent`. Some finesse is required to handle + // the cast of TypedArray arguments over the protocol, which is + // taken care of below. The event will cast the argument back + // into an array to be broadcasted from WebAudioActor, but the + // double-casting will only occur when starting from `addAutomationEvent`, + // which is only used in tests. + let param = XPCNativeWrapper.unwrap(node[paramName]); + let contentGlobal = Cu.getGlobalForObject(param); + let contentArgs = Cu.cloneInto(args, contentGlobal); + + // If calling `setValueCurveAtTime`, the first argument + // is a Float32Array, which won't be able to be serialized + // over the protocol. Cast a normal array to a Float32Array here. + if (eventName === "setValueCurveAtTime") { + // Create a Float32Array from the content, seeding with an array + // from the same scope. + let curve = new contentGlobal.Float32Array(contentArgs[0]); + contentArgs[0] = curve; + } + + // Apply the args back from the content scope, which is necessary + // due to the method wrapping changing in bug 1130901 to be exported + // directly to the content scope. + param[eventName].apply(param, contentArgs); + } catch (e) { + return constructError(e); + } + }, + + /** + * Registers the automation event in the AudioNodeActor's + * internal timeline. Called when setting automation via + * `addAutomationEvent`, or from the WebAudioActor's listening + * to the event firing via content. + * + * @param String paramName + * Name of the AudioParam. + * @param String eventName + * Name of the automation event called. + * @param Array args + * Arguments passed into the automation call. + */ + _recordAutomationEvent: function (paramName, eventName, args) { + let timeline = this.automation[paramName]; + timeline[eventName].apply(timeline, args); + } +}); + +/** + * The Web Audio Actor handles simple interaction with an AudioContext + * high-level methods. After instantiating this actor, you'll need to set it + * up by calling setup(). + */ +var WebAudioActor = exports.WebAudioActor = protocol.ActorClassWithSpec(webAudioSpec, { + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + + this._onContentFunctionCall = this._onContentFunctionCall.bind(this); + + // Store ChromeOnly ID (`nativeID` property on AudioNodeActor) mapped + // to the associated actorID, so we don't have to expose `nativeID` + // to the client in any way. + this._nativeToActorID = new Map(); + + this._onDestroyNode = this._onDestroyNode.bind(this); + this._onGlobalDestroyed = this._onGlobalDestroyed.bind(this); + this._onGlobalCreated = this._onGlobalCreated.bind(this); + }, + + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + this.finalize(); + }, + + /** + * Returns definition of all AudioNodes, such as AudioParams, and + * flags. + */ + getDefinition: function () { + return AUDIO_NODE_DEFINITION; + }, + + /** + * Starts waiting for the current tab actor's document global to be + * created, in order to instrument the Canvas context and become + * aware of everything the content does with Web Audio. + * + * See ContentObserver and WebAudioInstrumenter for more details. + */ + setup: function ({ reload }) { + // Used to track when something is happening with the web audio API + // the first time, to ultimately fire `start-context` event + this._firstNodeCreated = false; + + // Clear out stored nativeIDs on reload as we do not want to track + // AudioNodes that are no longer on this document. + this._nativeToActorID.clear(); + + if (this._initialized) { + if (reload) { + this.tabActor.window.location.reload(); + } + return; + } + + this._initialized = true; + + this._callWatcher = new CallWatcherActor(this.conn, this.tabActor); + this._callWatcher.onCall = this._onContentFunctionCall; + this._callWatcher.setup({ + tracedGlobals: AUDIO_GLOBALS, + startRecording: true, + performReload: reload, + holdWeak: true, + storeCalls: false + }); + // Bind to `window-ready` so we can reenable recording on the + // call watcher + on(this.tabActor, "window-ready", this._onGlobalCreated); + // Bind to the `window-destroyed` event so we can unbind events between + // the global destruction and the `finalize` cleanup method on the actor. + on(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + }, + + /** + * Invoked whenever an instrumented function is called, like an AudioContext + * method or an AudioNode method. + */ + _onContentFunctionCall: function (functionCall) { + let { name } = functionCall.details; + + // All Web Audio nodes inherit from AudioNode's prototype, so + // hook into the `connect` and `disconnect` methods + if (WebAudioFront.NODE_ROUTING_METHODS.has(name)) { + this._handleRoutingCall(functionCall); + } + else if (WebAudioFront.NODE_CREATION_METHODS.has(name)) { + this._handleCreationCall(functionCall); + } + else if (ENABLE_AUTOMATION && WebAudioFront.AUTOMATION_METHODS.has(name)) { + this._handleAutomationCall(functionCall); + } + }, + + _handleRoutingCall: function (functionCall) { + let { caller, args, name } = functionCall.details; + let source = caller; + let dest = args[0]; + let isAudioParam = dest ? getConstructorName(dest) === "AudioParam" : false; + + // audionode.connect(param) + if (name === "connect" && isAudioParam) { + this._onConnectParam(source, dest); + } + // audionode.connect(node) + else if (name === "connect") { + this._onConnectNode(source, dest); + } + // audionode.disconnect() + else if (name === "disconnect") { + this._onDisconnectNode(source); + } + }, + + _handleCreationCall: function (functionCall) { + let { caller, result } = functionCall.details; + // Keep track of the first node created, so we can alert + // the front end that an audio context is being used since + // we're not hooking into the constructor itself, just its + // instance's methods. + if (!this._firstNodeCreated) { + // Fire the start-up event if this is the first node created + // and trigger a `create-node` event for the context destination + this._onStartContext(); + this._onCreateNode(caller.destination); + this._firstNodeCreated = true; + } + this._onCreateNode(result); + }, + + _handleAutomationCall: function (functionCall) { + let { caller, name, args } = functionCall.details; + let wrappedParam = new XPCNativeWrapper(caller); + + // Sanitize arguments, as these should all be numbers, + // with the exception of a TypedArray, which needs + // casted to an Array + args = sanitizeAutomationArgs(args); + + let nodeActor = this._getActorByNativeID(wrappedParam._parentID); + nodeActor._recordAutomationEvent(wrappedParam._paramName, name, args); + + this._onAutomationEvent({ + node: nodeActor, + paramName: wrappedParam._paramName, + eventName: name, + args: args + }); + }, + + /** + * Stops listening for document global changes and puts this actor + * to hibernation. This method is called automatically just before the + * actor is destroyed. + */ + finalize: function () { + if (!this._initialized) { + return; + } + this._initialized = false; + systemOff("webaudio-node-demise", this._onDestroyNode); + + off(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + off(this.tabActor, "window-ready", this._onGlobalCreated); + this.tabActor = null; + this._nativeToActorID = null; + this._callWatcher.eraseRecording(); + this._callWatcher.finalize(); + this._callWatcher = null; + }, + + /** + * Helper for constructing an AudioNodeActor, assigning to + * internal weak map, and tracking via `manage` so it is assigned + * an `actorID`. + */ + _constructAudioNode: function (node) { + // Ensure AudioNode is wrapped. + node = new XPCNativeWrapper(node); + + this._instrumentParams(node); + + let actor = new AudioNodeActor(this.conn, node); + this.manage(actor); + this._nativeToActorID.set(node.id, actor.actorID); + return actor; + }, + + /** + * Takes an XrayWrapper node, and attaches the node's `nativeID` + * to the AudioParams as `_parentID`, as well as the the type of param + * as a string on `_paramName`. + */ + _instrumentParams: function (node) { + let type = getConstructorName(node); + Object.keys(AUDIO_NODE_DEFINITION[type].properties || {}) + .filter(isAudioParam.bind(null, node)) + .forEach(paramName => { + let param = node[paramName]; + param._parentID = node.id; + param._paramName = paramName; + }); + }, + + /** + * Takes an AudioNode and returns the stored actor for it. + * In some cases, we won't have an actor stored (for example, + * connecting to an AudioDestinationNode, since it's implicitly + * created), so make a new actor and store that. + */ + _getActorByNativeID: function (nativeID) { + // Ensure we have a Number, rather than a string + // return via notification. + nativeID = ~~nativeID; + + let actorID = this._nativeToActorID.get(nativeID); + let actor = actorID != null ? this.conn.getActor(actorID) : null; + return actor; + }, + + /** + * Called on first audio node creation, signifying audio context usage + */ + _onStartContext: function () { + systemOn("webaudio-node-demise", this._onDestroyNode); + emit(this, "start-context"); + }, + + /** + * Called when one audio node is connected to another. + */ + _onConnectNode: function (source, dest) { + let sourceActor = this._getActorByNativeID(source.id); + let destActor = this._getActorByNativeID(dest.id); + + emit(this, "connect-node", { + source: sourceActor, + dest: destActor + }); + }, + + /** + * Called when an audio node is connected to an audio param. + */ + _onConnectParam: function (source, param) { + let sourceActor = this._getActorByNativeID(source.id); + let destActor = this._getActorByNativeID(param._parentID); + emit(this, "connect-param", { + source: sourceActor, + dest: destActor, + param: param._paramName + }); + }, + + /** + * Called when an audio node is disconnected. + */ + _onDisconnectNode: function (node) { + let actor = this._getActorByNativeID(node.id); + emit(this, "disconnect-node", actor); + }, + + /** + * Called when a parameter changes on an audio node + */ + _onParamChange: function (node, param, value) { + let actor = this._getActorByNativeID(node.id); + emit(this, "param-change", { + source: actor, + param: param, + value: value + }); + }, + + /** + * Called on node creation. + */ + _onCreateNode: function (node) { + let actor = this._constructAudioNode(node); + emit(this, "create-node", actor); + }, + + /** Called when `webaudio-node-demise` is triggered, + * and emits the associated actor to the front if found. + */ + _onDestroyNode: function ({data}) { + // Cast to integer. + let nativeID = ~~data; + + let actor = this._getActorByNativeID(nativeID); + + // If actorID exists, emit; in the case where we get demise + // notifications for a document that no longer exists, + // the mapping should not be found, so we do not emit an event. + if (actor) { + this._nativeToActorID.delete(nativeID); + emit(this, "destroy-node", actor); + } + }, + + /** + * Ensures that the new global has recording on + * so we can proxy the function calls. + */ + _onGlobalCreated: function () { + // Used to track when something is happening with the web audio API + // the first time, to ultimately fire `start-context` event + this._firstNodeCreated = false; + + // Clear out stored nativeIDs on reload as we do not want to track + // AudioNodes that are no longer on this document. + this._nativeToActorID.clear(); + + this._callWatcher.resumeRecording(); + }, + + /** + * Fired when an automation event is added to an AudioNode. + */ + _onAutomationEvent: function ({node, paramName, eventName, args}) { + emit(this, "automation-event", { + node: node, + paramName: paramName, + eventName: eventName, + args: args + }); + }, + + /** + * Called when the underlying ContentObserver fires `global-destroyed` + * so we can cleanup some things between the global being destroyed and + * when the actor's `finalize` method gets called. + */ + _onGlobalDestroyed: function ({id}) { + if (this._callWatcher._tracedWindowId !== id) { + return; + } + + if (this._nativeToActorID) { + this._nativeToActorID.clear(); + } + systemOff("webaudio-node-demise", this._onDestroyNode); + } +}); + +/** + * Determines whether or not property is an AudioParam. + * + * @param AudioNode node + * An AudioNode. + * @param String prop + * Property of `node` to evaluate to see if it's an AudioParam. + * @return Boolean + */ +function isAudioParam(node, prop) { + return !!(node[prop] && /AudioParam/.test(node[prop].toString())); +} + +/** + * Takes an `Error` object and constructs a JSON-able response + * + * @param Error err + * A TypeError, RangeError, etc. + * @return Object + */ +function constructError(err) { + return { + message: err.message, + type: err.constructor.name + }; +} + +/** + * Creates and returns a JSON-able response used to indicate + * attempt to access an AudioNode that has been GC'd. + * + * @return Object + */ +function CollectedAudioNodeError() { + return { + message: "AudioNode has been garbage collected and can no longer be reached.", + type: "UnreachableAudioNode" + }; +} + +function InvalidCommandError() { + return { + message: "The command on AudioNode is invalid.", + type: "InvalidCommand" + }; +} + +/** + * Takes an object and converts it's `toString()` form, like + * "[object OscillatorNode]" or "[object Float32Array]", + * or XrayWrapper objects like "[object XrayWrapper [object Array]]" + * to a string of just the constructor name, like "OscillatorNode", + * or "Float32Array". + */ +function getConstructorName(obj) { + return Object.prototype.toString.call(obj).match(/\[object ([^\[\]]*)\]\]?$/)[1]; +} + +/** + * Create a grip-like object to pass in renderable information + * to the front-end for things like Float32Arrays, AudioBuffers, + * without tracking them in an actor pool. + */ +function createObjectGrip(value) { + return { + type: "object", + preview: { + kind: "ObjectWithText", + text: "" + }, + class: getConstructorName(value) + }; +} + +/** + * Converts all TypedArrays of the array that cannot + * be passed over the wire into a normal Array equivilent. + */ +function sanitizeAutomationArgs(args) { + return args.reduce((newArgs, el) => { + newArgs.push(typeof el === "object" && getConstructorName(el) === "Float32Array" ? castToArray(el) : el); + return newArgs; + }, []); +} + +/** + * Casts TypedArray to a normal array via a + * new scope. + */ +function castToArray(typedArray) { + // The Xray machinery for TypedArrays denies indexed access on the grounds + // that it's slow, and advises callers to do a structured clone instead. + let global = Cu.getGlobalForObject(this); + let safeView = Cu.cloneInto(typedArray.subarray(), global); + return copyInto([], safeView); +} + +/** + * Copies values of an array-like `source` into + * a similarly array-like `dest`. + */ +function copyInto(dest, source) { + for (let i = 0; i < source.length; i++) { + dest[i] = source[i]; + } + return dest; +} diff --git a/devtools/server/actors/webbrowser.js b/devtools/server/actors/webbrowser.js new file mode 100644 index 000000000..0edcdc187 --- /dev/null +++ b/devtools/server/actors/webbrowser.js @@ -0,0 +1,2529 @@ +/* -*- 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"; + +/* global XPCNativeWrapper */ + +var { Ci, Cu, Cr } = require("chrome"); +var Services = require("Services"); +var { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); +var promise = require("promise"); +var { + ActorPool, createExtraActors, appendExtraActors, GeneratedLocation +} = require("devtools/server/actors/common"); +var { DebuggerServer } = require("devtools/server/main"); +var DevToolsUtils = require("devtools/shared/DevToolsUtils"); +var { assert } = DevToolsUtils; +var { TabSources } = require("./utils/TabSources"); +var makeDebugger = require("./utils/make-debugger"); + +loader.lazyRequireGetter(this, "RootActor", "devtools/server/actors/root", true); +loader.lazyRequireGetter(this, "ThreadActor", "devtools/server/actors/script", true); +loader.lazyRequireGetter(this, "unwrapDebuggerObjectGlobal", "devtools/server/actors/script", true); +loader.lazyRequireGetter(this, "BrowserAddonActor", "devtools/server/actors/addon", true); +loader.lazyRequireGetter(this, "WebExtensionActor", "devtools/server/actors/webextension", true); +loader.lazyRequireGetter(this, "WorkerActorList", "devtools/server/actors/worker", true); +loader.lazyRequireGetter(this, "ServiceWorkerRegistrationActorList", "devtools/server/actors/worker", true); +loader.lazyRequireGetter(this, "ProcessActorList", "devtools/server/actors/process", true); +loader.lazyImporter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm"); +loader.lazyImporter(this, "ExtensionContent", "resource://gre/modules/ExtensionContent.jsm"); + +// Assumptions on events module: +// events needs to be dispatched synchronously, +// by calling the listeners in the order or registration. +loader.lazyRequireGetter(this, "events", "sdk/event/core"); + +loader.lazyRequireGetter(this, "StyleSheetActor", "devtools/server/actors/stylesheets", true); + +function getWindowID(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .currentInnerWindowID; +} + +function getDocShellChromeEventHandler(docShell) { + let handler = docShell.chromeEventHandler; + if (!handler) { + try { + // Toplevel xul window's docshell doesn't have chromeEventHandler + // attribute. The chrome event handler is just the global window object. + handler = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + } catch (e) { + // ignore + } + } + return handler; +} + +function getChildDocShells(parentDocShell) { + let docShellsEnum = parentDocShell.getDocShellEnumerator( + Ci.nsIDocShellTreeItem.typeAll, + Ci.nsIDocShell.ENUMERATE_FORWARDS + ); + + let docShells = []; + while (docShellsEnum.hasMoreElements()) { + let docShell = docShellsEnum.getNext(); + docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + docShells.push(docShell); + } + return docShells; +} + +exports.getChildDocShells = getChildDocShells; + +/** + * Browser-specific actors. + */ + +function getInnerId(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID; +} + +/** + * Yield all windows of type |windowType|, from the oldest window to the + * youngest, using nsIWindowMediator::getEnumerator. We're usually + * interested in "navigator:browser" windows. + */ +function* allAppShellDOMWindows(windowType) { + let e = Services.wm.getEnumerator(windowType); + while (e.hasMoreElements()) { + yield e.getNext(); + } +} + +exports.allAppShellDOMWindows = allAppShellDOMWindows; + +/** + * Retrieve the window type of the top-level window |window|. + */ +function appShellDOMWindowType(window) { + /* This is what nsIWindowMediator's enumerator checks. */ + return window.document.documentElement.getAttribute("windowtype"); +} + +/** + * Send Debugger:Shutdown events to all "navigator:browser" windows. + */ +function sendShutdownEvent() { + for (let win of allAppShellDOMWindows(DebuggerServer.chromeWindowType)) { + let evt = win.document.createEvent("Event"); + evt.initEvent("Debugger:Shutdown", true, false); + win.document.documentElement.dispatchEvent(evt); + } +} + +exports.sendShutdownEvent = sendShutdownEvent; + +/** + * Construct a root actor appropriate for use in a server running in a + * browser. The returned root actor: + * - respects the factories registered with DebuggerServer.addGlobalActor, + * - uses a BrowserTabList to supply tab actors, + * - sends all navigator:browser window documents a Debugger:Shutdown event + * when it exits. + * + * * @param connection DebuggerServerConnection + * The conection to the client. + */ +function createRootActor(connection) { + return new RootActor(connection, { + tabList: new BrowserTabList(connection), + addonList: new BrowserAddonList(connection), + workerList: new WorkerActorList(connection, {}), + serviceWorkerRegistrationList: + new ServiceWorkerRegistrationActorList(connection), + processList: new ProcessActorList(), + globalActorFactories: DebuggerServer.globalActorFactories, + onShutdown: sendShutdownEvent + }); +} + +/** + * A live list of BrowserTabActors representing the current browser tabs, + * to be provided to the root actor to answer 'listTabs' requests. + * + * This object also takes care of listening for TabClose events and + * onCloseWindow notifications, and exiting the BrowserTabActors concerned. + * + * (See the documentation for RootActor for the definition of the "live + * list" interface.) + * + * @param connection DebuggerServerConnection + * The connection in which this list's tab actors may participate. + * + * Some notes: + * + * This constructor is specific to the desktop browser environment; it + * maintains the tab list by tracking XUL windows and their XUL documents' + * "tabbrowser", "tab", and "browser" elements. What's entailed in maintaining + * an accurate list of open tabs in this context? + * + * - Opening and closing XUL windows: + * + * An nsIWindowMediatorListener is notified when new XUL windows (i.e., desktop + * windows) are opened and closed. It is not notified of individual content + * browser tabs coming and going within such a XUL window. That seems + * reasonable enough; it's concerned with XUL windows, not tab elements in the + * window's XUL document. + * + * However, even if we attach TabOpen and TabClose event listeners to each XUL + * window as soon as it is created: + * + * - we do not receive a TabOpen event for the initial empty tab of a new XUL + * window; and + * + * - we do not receive TabClose events for the tabs of a XUL window that has + * been closed. + * + * This means that TabOpen and TabClose events alone are not sufficient to + * maintain an accurate list of live tabs and mark tab actors as closed + * promptly. Our nsIWindowMediatorListener onCloseWindow handler must find and + * exit all actors for tabs that were in the closing window. + * + * Since this is a bit hairy, we don't make each individual attached tab actor + * responsible for noticing when it has been closed; we watch for that, and + * promise to call each actor's 'exit' method when it's closed, regardless of + * how we learn the news. + * + * - nsIWindowMediator locks + * + * nsIWindowMediator holds a lock protecting its list of top-level windows + * while it calls nsIWindowMediatorListener methods. nsIWindowMediator's + * GetEnumerator method also tries to acquire that lock. Thus, enumerating + * windows from within a listener method deadlocks (bug 873589). Rah. One + * can sometimes work around this by leaving the enumeration for a later + * tick. + * + * - Dragging tabs between windows: + * + * When a tab is dragged from one desktop window to another, we receive a + * TabOpen event for the new tab, and a TabClose event for the old tab; tab XUL + * elements do not really move from one document to the other (although their + * linked browser's content window objects do). + * + * However, while we could thus assume that each tab stays with the XUL window + * it belonged to when it was created, I'm not sure this is behavior one should + * rely upon. When a XUL window is closed, we take the less efficient, more + * conservative approach of simply searching the entire table for actors that + * belong to the closing XUL window, rather than trying to somehow track which + * XUL window each tab belongs to. + * + * - Title changes: + * + * For tabs living in the child process, we listen for DOMTitleChange message + * via the top-level window's message manager. Doing this also allows listening + * for title changes on Fennec. + * But as these messages aren't sent for tabs loaded in the parent process, + * we also listen for TabAttrModified event, which is fired only on Firefox + * desktop. + */ +function BrowserTabList(connection) { + this._connection = connection; + + /* + * The XUL document of a tabbed browser window has "tab" elements, whose + * 'linkedBrowser' JavaScript properties are "browser" elements; those + * browsers' 'contentWindow' properties are wrappers on the tabs' content + * window objects. + * + * This map's keys are "browser" XUL elements; it maps each browser element + * to the tab actor we've created for its content window, if we've created + * one. This map serves several roles: + * + * - During iteration, we use it to find actors we've created previously. + * + * - On a TabClose event, we use it to find the tab's actor and exit it. + * + * - When the onCloseWindow handler is called, we iterate over it to find all + * tabs belonging to the closing XUL window, and exit them. + * + * - When it's empty, and the onListChanged hook is null, we know we can + * stop listening for events and notifications. + * + * We listen for TabClose events and onCloseWindow notifications in order to + * send onListChanged notifications, but also to tell actors when their + * referent has gone away and remove entries for dead browsers from this map. + * If that code is working properly, neither this map nor the actors in it + * should ever hold dead tabs alive. + */ + this._actorByBrowser = new Map(); + + /* The current onListChanged handler, or null. */ + this._onListChanged = null; + + /* + * True if we've been iterated over since we last called our onListChanged + * hook. + */ + this._mustNotify = false; + + /* True if we're testing, and should throw if consistency checks fail. */ + this._testing = false; +} + +BrowserTabList.prototype.constructor = BrowserTabList; + +/** + * Get the selected browser for the given navigator:browser window. + * @private + * @param window nsIChromeWindow + * The navigator:browser window for which you want the selected browser. + * @return nsIDOMElement|null + * The currently selected xul:browser element, if any. Note that the + * browser window might not be loaded yet - the function will return + * |null| in such cases. + */ +BrowserTabList.prototype._getSelectedBrowser = function (window) { + return window.gBrowser ? window.gBrowser.selectedBrowser : null; +}; + +/** + * Produces an iterable (in this case a generator) to enumerate all available + * browser tabs. + */ +BrowserTabList.prototype._getBrowsers = function* () { + // Iterate over all navigator:browser XUL windows. + for (let win of allAppShellDOMWindows(DebuggerServer.chromeWindowType)) { + // For each tab in this XUL window, ensure that we have an actor for + // it, reusing existing actors where possible. We actually iterate + // over 'browser' XUL elements, and BrowserTabActor uses + // browser.contentWindow as the debuggee global. + for (let browser of this._getChildren(win)) { + yield browser; + } + } +}; + +BrowserTabList.prototype._getChildren = function (window) { + if (!window.gBrowser) { + return []; + } + let { gBrowser } = window; + if (!gBrowser.browsers) { + return []; + } + return gBrowser.browsers.filter(browser => { + // Filter tabs that are closing. listTabs calls made right after TabClose + // events still list tabs in process of being closed. + let tab = gBrowser.getTabForBrowser(browser); + return !tab.closing; + }); +}; + +BrowserTabList.prototype.getList = function () { + let topXULWindow = Services.wm.getMostRecentWindow( + DebuggerServer.chromeWindowType); + let selectedBrowser = null; + if (topXULWindow) { + selectedBrowser = this._getSelectedBrowser(topXULWindow); + } + + // As a sanity check, make sure all the actors presently in our map get + // picked up when we iterate over all windows' tabs. + let initialMapSize = this._actorByBrowser.size; + this._foundCount = 0; + + // To avoid mysterious behavior if tabs are closed or opened mid-iteration, + // we update the map first, and then make a second pass over it to yield + // the actors. Thus, the sequence yielded is always a snapshot of the + // actors that were live when we began the iteration. + + let actorPromises = []; + + for (let browser of this._getBrowsers()) { + let selected = browser === selectedBrowser; + actorPromises.push( + this._getActorForBrowser(browser) + .then(actor => { + // Set the 'selected' properties on all actors correctly. + actor.selected = selected; + return actor; + }) + ); + } + + if (this._testing && initialMapSize !== this._foundCount) { + throw new Error("_actorByBrowser map contained actors for dead tabs"); + } + + this._mustNotify = true; + this._checkListening(); + + return promise.all(actorPromises); +}; + +BrowserTabList.prototype._getActorForBrowser = function (browser) { + // Do we have an existing actor for this browser? If not, create one. + let actor = this._actorByBrowser.get(browser); + if (actor) { + this._foundCount++; + return actor.update(); + } + + actor = new BrowserTabActor(this._connection, browser); + this._actorByBrowser.set(browser, actor); + this._checkListening(); + return actor.connect(); +}; + +BrowserTabList.prototype.getTab = function ({ outerWindowID, tabId }) { + if (typeof outerWindowID == "number") { + // First look for in-process frames with this ID + let window = Services.wm.getOuterWindowWithId(outerWindowID); + // Safety check to prevent debugging top level window via getTab + if (window instanceof Ci.nsIDOMChromeWindow) { + return promise.reject({ + error: "forbidden", + message: "Window with outerWindowID '" + outerWindowID + "' is chrome" + }); + } + if (window) { + let iframe = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .containerElement; + if (iframe) { + return this._getActorForBrowser(iframe); + } + } + // Then also look on registered <xul:browsers> when using outerWindowID for + // OOP tabs + for (let browser of this._getBrowsers()) { + if (browser.outerWindowID == outerWindowID) { + return this._getActorForBrowser(browser); + } + } + return promise.reject({ + error: "noTab", + message: "Unable to find tab with outerWindowID '" + outerWindowID + "'" + }); + } else if (typeof tabId == "number") { + // Tabs OOP + for (let browser of this._getBrowsers()) { + if (browser.frameLoader.tabParent && + browser.frameLoader.tabParent.tabId === tabId) { + return this._getActorForBrowser(browser); + } + } + return promise.reject({ + error: "noTab", + message: "Unable to find tab with tabId '" + tabId + "'" + }); + } + + let topXULWindow = Services.wm.getMostRecentWindow( + DebuggerServer.chromeWindowType); + if (topXULWindow) { + let selectedBrowser = this._getSelectedBrowser(topXULWindow); + return this._getActorForBrowser(selectedBrowser); + } + return promise.reject({ + error: "noTab", + message: "Unable to find any selected browser" + }); +}; + +Object.defineProperty(BrowserTabList.prototype, "onListChanged", { + enumerable: true, + configurable: true, + get() { + return this._onListChanged; + }, + set(v) { + if (v !== null && typeof v !== "function") { + throw new Error( + "onListChanged property may only be set to 'null' or a function"); + } + this._onListChanged = v; + this._checkListening(); + } +}); + +/** + * The set of tabs has changed somehow. Call our onListChanged handler, if + * one is set, and if we haven't already called it since the last iteration. + */ +BrowserTabList.prototype._notifyListChanged = function () { + if (!this._onListChanged) { + return; + } + if (this._mustNotify) { + this._onListChanged(); + this._mustNotify = false; + } +}; + +/** + * Exit |actor|, belonging to |browser|, and notify the onListChanged + * handle if needed. + */ +BrowserTabList.prototype._handleActorClose = function (actor, browser) { + if (this._testing) { + if (this._actorByBrowser.get(browser) !== actor) { + throw new Error("BrowserTabActor not stored in map under given browser"); + } + if (actor.browser !== browser) { + throw new Error("actor's browser and map key don't match"); + } + } + + this._actorByBrowser.delete(browser); + actor.exit(); + + this._notifyListChanged(); + this._checkListening(); +}; + +/** + * Make sure we are listening or not listening for activity elsewhere in + * the browser, as appropriate. Other than setting up newly created XUL + * windows, all listener / observer connection and disconnection should + * happen here. + */ +BrowserTabList.prototype._checkListening = function () { + /* + * If we have an onListChanged handler that we haven't sent an announcement + * to since the last iteration, we need to watch for tab creation as well as + * change of the currently selected tab and tab title changes of tabs in + * parent process via TabAttrModified (tabs oop uses DOMTitleChanges). + * + * Oddly, we don't need to watch for 'close' events here. If our actor list + * is empty, then either it was empty the last time we iterated, and no + * close events are possible, or it was not empty the last time we + * iterated, but all the actors have since been closed, and we must have + * sent a notification already when they closed. + */ + this._listenForEventsIf(this._onListChanged && this._mustNotify, + "_listeningForTabOpen", + ["TabOpen", "TabSelect", "TabAttrModified"]); + + /* If we have live actors, we need to be ready to mark them dead. */ + this._listenForEventsIf(this._actorByBrowser.size > 0, + "_listeningForTabClose", + ["TabClose", "TabRemotenessChange"]); + + /* + * We must listen to the window mediator in either case, since that's the + * only way to find out about tabs that come and go when top-level windows + * are opened and closed. + */ + this._listenToMediatorIf((this._onListChanged && this._mustNotify) || + (this._actorByBrowser.size > 0)); + + /* + * We also listen for title changed from the child process. + * This allows listening for title changes from Fennec and OOP tabs in Fx. + */ + this._listenForMessagesIf(this._onListChanged && this._mustNotify, + "_listeningForTitleChange", + ["DOMTitleChanged"]); +}; + +/* + * Add or remove event listeners for all XUL windows. + * + * @param shouldListen boolean + * True if we should add event handlers; false if we should remove them. + * @param guard string + * The name of a guard property of 'this', indicating whether we're + * already listening for those events. + * @param eventNames array of strings + * An array of event names. + */ +BrowserTabList.prototype._listenForEventsIf = + function (shouldListen, guard, eventNames) { + if (!shouldListen !== !this[guard]) { + let op = shouldListen ? "addEventListener" : "removeEventListener"; + for (let win of allAppShellDOMWindows(DebuggerServer.chromeWindowType)) { + for (let name of eventNames) { + win[op](name, this, false); + } + } + this[guard] = shouldListen; + } + }; + +/* + * Add or remove message listeners for all XUL windows. + * + * @param aShouldListen boolean + * True if we should add message listeners; false if we should remove them. + * @param aGuard string + * The name of a guard property of 'this', indicating whether we're + * already listening for those messages. + * @param aMessageNames array of strings + * An array of message names. + */ +BrowserTabList.prototype._listenForMessagesIf = + function (shouldListen, guard, messageNames) { + if (!shouldListen !== !this[guard]) { + let op = shouldListen ? "addMessageListener" : "removeMessageListener"; + for (let win of allAppShellDOMWindows(DebuggerServer.chromeWindowType)) { + for (let name of messageNames) { + win.messageManager[op](name, this); + } + } + this[guard] = shouldListen; + } + }; + +/** + * Implement nsIMessageListener. + */ +BrowserTabList.prototype.receiveMessage = DevToolsUtils.makeInfallible( + function (message) { + let browser = message.target; + switch (message.name) { + case "DOMTitleChanged": { + let actor = this._actorByBrowser.get(browser); + if (actor) { + this._notifyListChanged(); + this._checkListening(); + } + break; + } + } + }); + +/** + * Implement nsIDOMEventListener. + */ +BrowserTabList.prototype.handleEvent = +DevToolsUtils.makeInfallible(function (event) { + let browser = event.target.linkedBrowser; + switch (event.type) { + case "TabOpen": + case "TabSelect": { + /* Don't create a new actor; iterate will take care of that. Just notify. */ + this._notifyListChanged(); + this._checkListening(); + break; + } + case "TabClose": { + let actor = this._actorByBrowser.get(browser); + if (actor) { + this._handleActorClose(actor, browser); + } + break; + } + case "TabRemotenessChange": { + // We have to remove the cached actor as we have to create a new instance. + let actor = this._actorByBrowser.get(browser); + if (actor) { + this._actorByBrowser.delete(browser); + // Don't create a new actor; iterate will take care of that. Just notify. + this._notifyListChanged(); + this._checkListening(); + } + break; + } + case "TabAttrModified": { + // Remote <browser> title changes are handled via DOMTitleChange message + // TabAttrModified is only here for browsers in parent process which + // don't send this message. + if (browser.isRemoteBrowser) { + break; + } + let actor = this._actorByBrowser.get(browser); + if (actor) { + // TabAttrModified is fired in various cases, here only care about title + // changes + if (event.detail.changed.includes("label")) { + this._notifyListChanged(); + this._checkListening(); + } + } + break; + } + } +}, "BrowserTabList.prototype.handleEvent"); + +/* + * If |shouldListen| is true, ensure we've registered a listener with the + * window mediator. Otherwise, ensure we haven't registered a listener. + */ +BrowserTabList.prototype._listenToMediatorIf = function (shouldListen) { + if (!shouldListen !== !this._listeningToMediator) { + let op = shouldListen ? "addListener" : "removeListener"; + Services.wm[op](this); + this._listeningToMediator = shouldListen; + } +}; + +/** + * nsIWindowMediatorListener implementation. + * + * See _onTabClosed for explanation of why we needn't actually tweak any + * actors or tables here. + * + * An nsIWindowMediatorListener's methods get passed all sorts of windows; we + * only care about the tab containers. Those have 'getBrowser' methods. + */ +BrowserTabList.prototype.onWindowTitleChange = () => { }; + +BrowserTabList.prototype.onOpenWindow = +DevToolsUtils.makeInfallible(function (window) { + let handleLoad = DevToolsUtils.makeInfallible(() => { + /* We don't want any further load events from this window. */ + window.removeEventListener("load", handleLoad, false); + + if (appShellDOMWindowType(window) !== DebuggerServer.chromeWindowType) { + return; + } + + // Listen for future tab activity. + if (this._listeningForTabOpen) { + window.addEventListener("TabOpen", this, false); + window.addEventListener("TabSelect", this, false); + window.addEventListener("TabAttrModified", this, false); + } + if (this._listeningForTabClose) { + window.addEventListener("TabClose", this, false); + window.addEventListener("TabRemotenessChange", this, false); + } + if (this._listeningForTitleChange) { + window.messageManager.addMessageListener("DOMTitleChanged", this); + } + + // As explained above, we will not receive a TabOpen event for this + // document's initial tab, so we must notify our client of the new tab + // this will have. + this._notifyListChanged(); + }); + + /* + * You can hardly do anything at all with a XUL window at this point; it + * doesn't even have its document yet. Wait until its document has + * loaded, and then see what we've got. This also avoids + * nsIWindowMediator enumeration from within listeners (bug 873589). + */ + window = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + + window.addEventListener("load", handleLoad, false); +}, "BrowserTabList.prototype.onOpenWindow"); + +BrowserTabList.prototype.onCloseWindow = +DevToolsUtils.makeInfallible(function (window) { + window = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + + if (appShellDOMWindowType(window) !== DebuggerServer.chromeWindowType) { + return; + } + + /* + * nsIWindowMediator deadlocks if you call its GetEnumerator method from + * a nsIWindowMediatorListener's onCloseWindow hook (bug 873589), so + * handle the close in a different tick. + */ + Services.tm.currentThread.dispatch(DevToolsUtils.makeInfallible(() => { + /* + * Scan the entire map for actors representing tabs that were in this + * top-level window, and exit them. + */ + for (let [browser, actor] of this._actorByBrowser) { + /* The browser document of a closed window has no default view. */ + if (!browser.ownerDocument.defaultView) { + this._handleActorClose(actor, browser); + } + } + }, "BrowserTabList.prototype.onCloseWindow's delayed body"), 0); +}, "BrowserTabList.prototype.onCloseWindow"); + +exports.BrowserTabList = BrowserTabList; + +/** + * Creates a TabActor whose main goal is to manage lifetime and + * expose the tab actors being registered via DebuggerServer.registerModule. + * But also track the lifetime of the document being tracked. + * + * ### Main requests: + * + * `attach`/`detach` requests: + * - start/stop document watching: + * Starts watching for new documents and emits `tabNavigated` and + * `frameUpdate` over RDP. + * - retrieve the thread actor: + * Instantiates a ThreadActor that can be later attached to in order to + * debug JS sources in the document. + * `switchToFrame`: + * Change the targeted document of the whole TabActor, and its child tab actors + * to an iframe or back to its original document. + * + * Most of the TabActor properties (like `chromeEventHandler` or `docShells`) + * are meant to be used by the various child tab actors. + * + * ### RDP events: + * + * - `tabNavigated`: + * Sent when the tab is about to navigate or has just navigated to + * a different document. + * This event contains the following attributes: + * * url (string) The new URI being loaded. + * * nativeConsoleAPI (boolean) `false` if the console API of the page has + * been overridden (e.g. by Firebug), + * `true` if the Gecko implementation is used. + * * state (string) `start` if we just start requesting the new URL, + * `stop` if the new URL is done loading. + * * isFrameSwitching (boolean) Indicates the event is dispatched when + * switching the TabActor context to + * a different frame. When we switch to + * an iframe, there is no document load. + * The targeted document is most likely + * going to be already done loading. + * * title (string) The document title being loaded. + * (sent only on state=stop) + * + * - `frameUpdate`: + * Sent when there was a change in the child frames contained in the document + * or when the tab's context was switched to another frame. + * This event can have four different forms depending on the type of change: + * * One or many frames are updated: + * { frames: [{ id, url, title, parentID }, ...] } + * * One frame got destroyed: + * { frames: [{ id, destroy: true }]} + * * All frames got destroyed: + * { destroyAll: true } + * * We switched the context of the TabActor to a specific frame: + * { selected: #id } + * + * ### Internal, non-rdp events: + * Various events are also dispatched on the TabActor itself that are not + * related to RDP, so, not sent to the client. They all relate to the documents + * tracked by the TabActor (its main targeted document, but also any of its + * iframes). + * - will-navigate + * This event fires once navigation starts. + * All pending user prompts are dealt with, + * but it is fired before the first request starts. + * - navigate + * This event is fired once the document's readyState is "complete". + * - window-ready + * This event is fired on three distinct scenarios: + * * When a new Window object is crafted, equivalent of `DOMWindowCreated`. + * It is dispatched before any page script is executed. + * * We will have already received a window-ready event for this window + * when it was created, but we received a window-destroyed event when + * it was frozen into the bfcache, and now the user navigated back to + * this page, so it's now live again and we should resume handling it. + * * For each existing document, when an `attach` request is received. + * At this point scripts in the page will be already loaded. + * - window-destroyed + * This event is fired in two cases: + * * When the window object is destroyed, i.e. when the related document + * is garbage collected. This can happen when the tab is closed or the + * iframe is removed from the DOM. + * It is equivalent of `inner-window-destroyed` event. + * * When the page goes into the bfcache and gets frozen. + * The equivalent of `pagehide`. + * - changed-toplevel-document + * This event fires when we switch the TabActor targeted document + * to one of its iframes, or back to its original top document. + * It is dispatched between window-destroyed and window-ready. + * - stylesheet-added + * This event is fired when a StyleSheetActor is created. + * It contains the following attribute : + * * actor (StyleSheetActor) The created actor. + * + * Note that *all* these events are dispatched in the following order + * when we switch the context of the TabActor to a given iframe: + * - will-navigate + * - window-destroyed + * - changed-toplevel-document + * - window-ready + * - navigate + * + * This class is subclassed by ContentActor and others. + * Subclasses are expected to implement a getter for the docShell property. + * + * @param connection DebuggerServerConnection + * The conection to the client. + */ +function TabActor(connection) { + this.conn = connection; + this._tabActorPool = null; + // A map of actor names to actor instances provided by extensions. + this._extraActors = {}; + this._exited = false; + this._sources = null; + + // Map of DOM stylesheets to StyleSheetActors + this._styleSheetActors = new Map(); + + this._shouldAddNewGlobalAsDebuggee = + this._shouldAddNewGlobalAsDebuggee.bind(this); + + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: () => { + return this.windows.concat(this.webextensionsContentScriptGlobals); + }, + shouldAddNewGlobalAsDebuggee: this._shouldAddNewGlobalAsDebuggee + }); + + // Flag eventually overloaded by sub classes in order to watch new docshells + // Used by the ChromeActor to list all frames in the Browser Toolbox + this.listenForNewDocShells = false; + + this.traits = { + reconfigure: true, + // Supports frame listing via `listFrames` request and `frameUpdate` events + // as well as frame switching via `switchToFrame` request + frames: true, + // Do not require to send reconfigure request to reset the document state + // to what it was before using the TabActor + noTabReconfigureOnClose: true + }; + + this._workerActorList = null; + this._workerActorPool = null; + this._onWorkerActorListChanged = this._onWorkerActorListChanged.bind(this); +} + +// XXX (bug 710213): TabActor attach/detach/exit/disconnect is a +// *complete* mess, needs to be rethought asap. + +TabActor.prototype = { + traits: null, + + // Optional console API listener options (e.g. used by the WebExtensionActor to + // filter console messages by addonID), set to an empty (no options) object by default. + consoleAPIListenerOptions: {}, + + // Optional TabSources filter function (e.g. used by the WebExtensionActor to filter + // sources by addonID), allow all sources by default. + _allowSource() { + return true; + }, + + get exited() { + return this._exited; + }, + + get attached() { + return !!this._attached; + }, + + _tabPool: null, + get tabActorPool() { + return this._tabPool; + }, + + _contextPool: null, + get contextActorPool() { + return this._contextPool; + }, + + // A constant prefix that will be used to form the actor ID by the server. + actorPrefix: "tab", + + /** + * An object on which listen for DOMWindowCreated and pageshow events. + */ + get chromeEventHandler() { + return getDocShellChromeEventHandler(this.docShell); + }, + + /** + * Getter for the nsIMessageManager associated to the tab. + */ + get messageManager() { + try { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIContentFrameMessageManager); + } catch (e) { + return null; + } + }, + + /** + * Getter for the tab's doc shell. + */ + get docShell() { + throw new Error( + "The docShell getter should be implemented by a subclass of TabActor"); + }, + + /** + * Getter for the list of all docshell in this tabActor + * @return {Array} + */ + get docShells() { + return getChildDocShells(this.docShell); + }, + + /** + * Getter for the tab content's DOM window. + */ + get window() { + // On xpcshell, there is no document + if (this.docShell) { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + } + return null; + }, + + get outerWindowID() { + if (this.window) { + return this.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .outerWindowID; + } + return null; + }, + + /** + * Getter for the WebExtensions ContentScript globals related to the + * current tab content's DOM window. + */ + get webextensionsContentScriptGlobals() { + // Ignore xpcshell runtime which spawn TabActors without a window. + if (this.window) { + return ExtensionContent.getContentScriptGlobalsForWindow(this.window); + } + + return []; + }, + + /** + * Getter for the list of all content DOM windows in this tabActor + * @return {Array} + */ + get windows() { + return this.docShells.map(docShell => { + return docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + }); + }, + + /** + * Getter for the original docShell the tabActor got attached to in the first + * place. + * Note that your actor should normally *not* rely on this top level docShell + * if you want it to show information relative to the iframe that's currently + * being inspected in the toolbox. + */ + get originalDocShell() { + if (!this._originalWindow) { + return this.docShell; + } + + return this._originalWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + }, + + /** + * Getter for the original window the tabActor got attached to in the first + * place. + * Note that your actor should normally *not* rely on this top level window if + * you want it to show information relative to the iframe that's currently + * being inspected in the toolbox. + */ + get originalWindow() { + return this._originalWindow || this.window; + }, + + /** + * Getter for the nsIWebProgress for watching this window. + */ + get webProgress() { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + }, + + /** + * Getter for the nsIWebNavigation for the tab. + */ + get webNavigation() { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation); + }, + + /** + * Getter for the tab's document. + */ + get contentDocument() { + return this.webNavigation.document; + }, + + /** + * Getter for the tab title. + * @return string + * Tab title. + */ + get title() { + return this.contentDocument.contentTitle; + }, + + /** + * Getter for the tab URL. + * @return string + * Tab URL. + */ + get url() { + if (this.webNavigation.currentURI) { + return this.webNavigation.currentURI.spec; + } + // Abrupt closing of the browser window may leave callbacks without a + // currentURI. + return null; + }, + + get sources() { + if (!this._sources) { + this._sources = new TabSources(this.threadActor, this._allowSource); + } + return this._sources; + }, + + /** + * This is called by BrowserTabList.getList for existing tab actors prior to + * calling |form| below. It can be used to do any async work that may be + * needed to assemble the form. + */ + update() { + return promise.resolve(this); + }, + + form() { + assert(!this.exited, + "form() shouldn't be called on exited browser actor."); + assert(this.actorID, + "tab should have an actorID."); + + let response = { + actor: this.actorID + }; + + // We may try to access window while the document is closing, then + // accessing window throws. Also on xpcshell we are using tabactor even if + // there is no valid document. + if (this.docShell && !this.docShell.isBeingDestroyed()) { + response.title = this.title; + response.url = this.url; + response.outerWindowID = this.outerWindowID; + } + + // Always use the same ActorPool, so existing actor instances + // (created in createExtraActors) are not lost. + if (!this._tabActorPool) { + this._tabActorPool = new ActorPool(this.conn); + this.conn.addActorPool(this._tabActorPool); + } + + // Walk over tab actor factories and make sure they are all + // instantiated and added into the ActorPool. Note that some + // factories can be added dynamically by extensions. + this._createExtraActors(DebuggerServer.tabActorFactories, + this._tabActorPool); + + this._appendExtraActors(response); + return response; + }, + + /** + * Called when the actor is removed from the connection. + */ + disconnect() { + this.exit(); + }, + + /** + * Called by the root actor when the underlying tab is closed. + */ + exit() { + if (this.exited) { + return; + } + + // Tell the thread actor that the tab is closed, so that it may terminate + // instead of resuming the debuggee script. + if (this._attached) { + this.threadActor._tabClosed = true; + } + + this._detach(); + + Object.defineProperty(this, "docShell", { + value: null, + configurable: true + }); + + this._extraActors = null; + + this._exited = true; + }, + + /** + * Return true if the given global is associated with this tab and should be + * added as a debuggee, false otherwise. + */ + _shouldAddNewGlobalAsDebuggee(wrappedGlobal) { + if (wrappedGlobal.hostAnnotations && + wrappedGlobal.hostAnnotations.type == "document" && + wrappedGlobal.hostAnnotations.element === this.window) { + return true; + } + + let global = unwrapDebuggerObjectGlobal(wrappedGlobal); + if (!global) { + return false; + } + + // Check if the global is a sdk page-mod sandbox. + let metadata = {}; + let id = ""; + try { + id = getInnerId(this.window); + metadata = Cu.getSandboxMetadata(global); + } catch (e) { + // ignore + } + if (metadata + && metadata["inner-window-id"] + && metadata["inner-window-id"] == id) { + return true; + } + + return false; + }, + + /* Support for DebuggerServer.addTabActor. */ + _createExtraActors: createExtraActors, + _appendExtraActors: appendExtraActors, + + /** + * Does the actual work of attaching to a tab. + */ + _attach() { + if (this._attached) { + return; + } + + // Create a pool for tab-lifetime actors. + assert(!this._tabPool, "Shouldn't have a tab pool if we weren't attached."); + this._tabPool = new ActorPool(this.conn); + this.conn.addActorPool(this._tabPool); + + // ... and a pool for context-lifetime actors. + this._pushContext(); + + // on xpcshell, there is no document + if (this.window) { + this._progressListener = new DebuggerProgressListener(this); + + // Save references to the original document we attached to + this._originalWindow = this.window; + + // Ensure replying to attach() request first + // before notifying about new docshells. + DevToolsUtils.executeSoon(() => this._watchDocshells()); + } + + this._attached = true; + }, + + _watchDocshells() { + // In child processes, we watch all docshells living in the process. + if (this.listenForNewDocShells) { + Services.obs.addObserver(this, "webnavigation-create", false); + } + Services.obs.addObserver(this, "webnavigation-destroy", false); + + // We watch for all child docshells under the current document, + this._progressListener.watch(this.docShell); + + // And list all already existing ones. + this._updateChildDocShells(); + }, + + onSwitchToFrame(request) { + let windowId = request.windowId; + let win; + + try { + win = Services.wm.getOuterWindowWithId(windowId); + } catch (e) { + // ignore + } + if (!win) { + return { error: "noWindow", + message: "The related docshell is destroyed or not found" }; + } else if (win == this.window) { + return {}; + } + + // Reply first before changing the document + DevToolsUtils.executeSoon(() => this._changeTopLevelDocument(win)); + + return {}; + }, + + onListFrames(request) { + let windows = this._docShellsToWindows(this.docShells); + return { frames: windows }; + }, + + onListWorkers(request) { + if (!this.attached) { + return { error: "wrongState" }; + } + + if (this._workerActorList === null) { + this._workerActorList = new WorkerActorList(this.conn, { + type: Ci.nsIWorkerDebugger.TYPE_DEDICATED, + window: this.window + }); + } + + return this._workerActorList.getList().then((actors) => { + let pool = new ActorPool(this.conn); + for (let actor of actors) { + pool.addActor(actor); + } + + this.conn.removeActorPool(this._workerActorPool); + this._workerActorPool = pool; + this.conn.addActorPool(this._workerActorPool); + + this._workerActorList.onListChanged = this._onWorkerActorListChanged; + + return { + "from": this.actorID, + "workers": actors.map((actor) => actor.form()) + }; + }); + }, + + _onWorkerActorListChanged() { + this._workerActorList.onListChanged = null; + this.conn.sendActorEvent(this.actorID, "workerListChanged"); + }, + + observe(subject, topic, data) { + // Ignore any event that comes before/after the tab actor is attached + // That typically happens during firefox shutdown. + if (!this.attached) { + return; + } + if (topic == "webnavigation-create") { + subject.QueryInterface(Ci.nsIDocShell); + this._onDocShellCreated(subject); + } else if (topic == "webnavigation-destroy") { + this._onDocShellDestroy(subject); + } + }, + + _onDocShellCreated(docShell) { + // (chrome-)webnavigation-create is fired very early during docshell + // construction. In new root docshells within child processes, involving + // TabChild, this event is from within this call: + // http://hg.mozilla.org/mozilla-central/annotate/74d7fb43bb44/dom/ipc/TabChild.cpp#l912 + // whereas the chromeEventHandler (and most likely other stuff) is set + // later: + // http://hg.mozilla.org/mozilla-central/annotate/74d7fb43bb44/dom/ipc/TabChild.cpp#l944 + // So wait a tick before watching it: + DevToolsUtils.executeSoon(() => { + // Bug 1142752: sometimes, the docshell appears to be immediately + // destroyed, bailout early to prevent random exceptions. + if (docShell.isBeingDestroyed()) { + return; + } + + // In child processes, we have new root docshells, + // let's watch them and all their child docshells. + if (this._isRootDocShell(docShell)) { + this._progressListener.watch(docShell); + } + this._notifyDocShellsUpdate([docShell]); + }); + }, + + _onDocShellDestroy(docShell) { + let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + this._notifyDocShellDestroy(webProgress); + }, + + _isRootDocShell(docShell) { + // Should report as root docshell: + // - New top level window's docshells, when using ChromeActor against a + // process. It allows tracking iframes of the newly opened windows + // like Browser console or new browser windows. + // - MozActivities or window.open frames on B2G, where a new root docshell + // is spawn in the child process of the app. + return !docShell.parent; + }, + + // Convert docShell list to windows objects list being sent to the client + _docShellsToWindows(docshells) { + return docshells.map(docShell => { + let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + let window = webProgress.DOMWindow; + let id = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .outerWindowID; + let parentID = undefined; + // Ignore the parent of the original document on non-e10s firefox, + // as we get the xul window as parent and don't care about it. + if (window.parent && window != this._originalWindow) { + parentID = window.parent + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .outerWindowID; + } + + // Collect the addonID from the document origin attributes. + let addonID = window.document.nodePrincipal.originAttributes.addonId; + + return { + id, + parentID, + addonID, + url: window.location.href, + title: window.document.title, + }; + }); + }, + + _notifyDocShellsUpdate(docshells) { + let windows = this._docShellsToWindows(docshells); + + // Do not send the `frameUpdate` event if the windows array is empty. + if (windows.length == 0) { + return; + } + + this.conn.send({ + from: this.actorID, + type: "frameUpdate", + frames: windows + }); + }, + + _updateChildDocShells() { + this._notifyDocShellsUpdate(this.docShells); + }, + + _notifyDocShellDestroy(webProgress) { + webProgress = webProgress.QueryInterface(Ci.nsIWebProgress); + let id = webProgress.DOMWindow + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .outerWindowID; + this.conn.send({ + from: this.actorID, + type: "frameUpdate", + frames: [{ + id, + destroy: true + }] + }); + + // Stop watching this docshell (the unwatch() method will check if we + // started watching it before). + webProgress.QueryInterface(Ci.nsIDocShell); + this._progressListener.unwatch(webProgress); + + if (webProgress.DOMWindow == this._originalWindow) { + // If the original top level document we connected to is removed, + // we try to switch to any other top level document + let rootDocShells = this.docShells + .filter(d => { + return d != this.docShell && + this._isRootDocShell(d); + }); + if (rootDocShells.length > 0) { + let newRoot = rootDocShells[0]; + this._originalWindow = newRoot.DOMWindow; + this._changeTopLevelDocument(this._originalWindow); + } else { + // If for some reason (typically during Firefox shutdown), the original + // document is destroyed, and there is no other top level docshell, + // we detach the tab actor to unregister all listeners and prevent any + // exception + this.exit(); + } + return; + } + + // If the currently targeted context is destroyed, + // and we aren't on the top-level document, + // we have to switch to the top-level one. + if (webProgress.DOMWindow == this.window && + this.window != this._originalWindow) { + this._changeTopLevelDocument(this._originalWindow); + } + }, + + _notifyDocShellDestroyAll() { + this.conn.send({ + from: this.actorID, + type: "frameUpdate", + destroyAll: true + }); + }, + + /** + * Creates a thread actor and a pool for context-lifetime actors. It then sets + * up the content window for debugging. + */ + _pushContext() { + assert(!this._contextPool, "Can't push multiple contexts"); + + this._contextPool = new ActorPool(this.conn); + this.conn.addActorPool(this._contextPool); + + this.threadActor = new ThreadActor(this, this.window); + this._contextPool.addActor(this.threadActor); + }, + + /** + * Exits the current thread actor and removes the context-lifetime actor pool. + * The content window is no longer being debugged after this call. + */ + _popContext() { + assert(!!this._contextPool, "No context to pop."); + + this.conn.removeActorPool(this._contextPool); + this._contextPool = null; + this.threadActor.exit(); + this.threadActor = null; + this._sources = null; + }, + + /** + * Does the actual work of detaching from a tab. + * + * @returns false if the tab wasn't attached or true of detaching succeeds. + */ + _detach() { + if (!this.attached) { + return false; + } + + // Check for docShell availability, as it can be already gone + // during Firefox shutdown. + if (this.docShell) { + this._progressListener.unwatch(this.docShell); + this._restoreDocumentSettings(); + } + if (this._progressListener) { + this._progressListener.destroy(); + this._progressListener = null; + this._originalWindow = null; + + // Removes the observers being set in _watchDocShells + if (this.listenForNewDocShells) { + Services.obs.removeObserver(this, "webnavigation-create"); + } + Services.obs.removeObserver(this, "webnavigation-destroy"); + } + + this._popContext(); + + // Shut down actors that belong to this tab's pool. + for (let sheetActor of this._styleSheetActors.values()) { + this._tabPool.removeActor(sheetActor); + } + this._styleSheetActors.clear(); + this.conn.removeActorPool(this._tabPool); + this._tabPool = null; + if (this._tabActorPool) { + this.conn.removeActorPool(this._tabActorPool); + this._tabActorPool = null; + } + + // Make sure that no more workerListChanged notifications are sent. + if (this._workerActorList !== null) { + this._workerActorList.onListChanged = null; + this._workerActorList = null; + } + + if (this._workerActorPool !== null) { + this.conn.removeActorPool(this._workerActorPool); + this._workerActorPool = null; + } + + this._attached = false; + + this.conn.send({ from: this.actorID, + type: "tabDetached" }); + + return true; + }, + + // Protocol Request Handlers + + onAttach(request) { + if (this.exited) { + return { type: "exited" }; + } + + this._attach(); + + return { + type: "tabAttached", + threadActor: this.threadActor.actorID, + cacheDisabled: this._getCacheDisabled(), + javascriptEnabled: this._getJavascriptEnabled(), + traits: this.traits, + }; + }, + + onDetach(request) { + if (!this._detach()) { + return { error: "wrongState" }; + } + + return { type: "detached" }; + }, + + /** + * Bring the tab's window to front. + */ + onFocus() { + if (this.window) { + this.window.focus(); + } + return {}; + }, + + /** + * Reload the page in this tab. + */ + onReload(request) { + let force = request && request.options && request.options.force; + // Wait a tick so that the response packet can be dispatched before the + // subsequent navigation event packet. + Services.tm.currentThread.dispatch(DevToolsUtils.makeInfallible(() => { + // This won't work while the browser is shutting down and we don't really + // care. + if (Services.startup.shuttingDown) { + return; + } + this.webNavigation.reload(force ? + Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE : + Ci.nsIWebNavigation.LOAD_FLAGS_NONE); + }, "TabActor.prototype.onReload's delayed body"), 0); + return {}; + }, + + /** + * Navigate this tab to a new location + */ + onNavigateTo(request) { + // Wait a tick so that the response packet can be dispatched before the + // subsequent navigation event packet. + Services.tm.currentThread.dispatch(DevToolsUtils.makeInfallible(() => { + this.window.location = request.url; + }, "TabActor.prototype.onNavigateTo's delayed body"), 0); + return {}; + }, + + /** + * Reconfigure options. + */ + onReconfigure(request) { + let options = request.options || {}; + + if (!this.docShell) { + // The tab is already closed. + return {}; + } + this._toggleDevToolsSettings(options); + + return {}; + }, + + /** + * Handle logic to enable/disable JS/cache/Service Worker testing. + */ + _toggleDevToolsSettings(options) { + // Wait a tick so that the response packet can be dispatched before the + // subsequent navigation event packet. + let reload = false; + + if (typeof options.javascriptEnabled !== "undefined" && + options.javascriptEnabled !== this._getJavascriptEnabled()) { + this._setJavascriptEnabled(options.javascriptEnabled); + reload = true; + } + if (typeof options.cacheDisabled !== "undefined" && + options.cacheDisabled !== this._getCacheDisabled()) { + this._setCacheDisabled(options.cacheDisabled); + } + if ((typeof options.serviceWorkersTestingEnabled !== "undefined") && + (options.serviceWorkersTestingEnabled !== + this._getServiceWorkersTestingEnabled())) { + this._setServiceWorkersTestingEnabled( + options.serviceWorkersTestingEnabled + ); + } + + // Reload if: + // - there's an explicit `performReload` flag and it's true + // - there's no `performReload` flag, but it makes sense to do so + let hasExplicitReloadFlag = "performReload" in options; + if ((hasExplicitReloadFlag && options.performReload) || + (!hasExplicitReloadFlag && reload)) { + this.onReload(); + } + }, + + /** + * Opposite of the _toggleDevToolsSettings method, that reset document state + * when closing the toolbox. + */ + _restoreDocumentSettings() { + this._restoreJavascript(); + this._setCacheDisabled(false); + this._setServiceWorkersTestingEnabled(false); + }, + + /** + * Disable or enable the cache via docShell. + */ + _setCacheDisabled(disabled) { + let enable = Ci.nsIRequest.LOAD_NORMAL; + let disable = Ci.nsIRequest.LOAD_BYPASS_CACHE | + Ci.nsIRequest.INHIBIT_CACHING; + + this.docShell.defaultLoadFlags = disabled ? disable : enable; + }, + + /** + * Disable or enable JS via docShell. + */ + _wasJavascriptEnabled: null, + _setJavascriptEnabled(allow) { + if (this._wasJavascriptEnabled === null) { + this._wasJavascriptEnabled = this.docShell.allowJavascript; + } + this.docShell.allowJavascript = allow; + }, + + /** + * Restore JS state, before the actor modified it. + */ + _restoreJavascript() { + if (this._wasJavascriptEnabled !== null) { + this._setJavascriptEnabled(this._wasJavascriptEnabled); + this._wasJavascriptEnabled = null; + } + }, + + /** + * Return JS allowed status. + */ + _getJavascriptEnabled() { + if (!this.docShell) { + // The tab is already closed. + return null; + } + + return this.docShell.allowJavascript; + }, + + /** + * Disable or enable the service workers testing features. + */ + _setServiceWorkersTestingEnabled(enabled) { + let windowUtils = this.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + windowUtils.serviceWorkersTestingEnabled = enabled; + }, + + /** + * Return cache allowed status. + */ + _getCacheDisabled() { + if (!this.docShell) { + // The tab is already closed. + return null; + } + + let disable = Ci.nsIRequest.LOAD_BYPASS_CACHE | + Ci.nsIRequest.INHIBIT_CACHING; + return this.docShell.defaultLoadFlags === disable; + }, + + /** + * Return service workers testing allowed status. + */ + _getServiceWorkersTestingEnabled() { + if (!this.docShell) { + // The tab is already closed. + return null; + } + + let windowUtils = this.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + return windowUtils.serviceWorkersTestingEnabled; + }, + + /** + * Prepare to enter a nested event loop by disabling debuggee events. + */ + preNest() { + if (!this.window) { + // The tab is already closed. + return; + } + let windowUtils = this.window + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + windowUtils.suppressEventHandling(true); + windowUtils.suspendTimeouts(); + }, + + /** + * Prepare to exit a nested event loop by enabling debuggee events. + */ + postNest(nestData) { + if (!this.window) { + // The tab is already closed. + return; + } + let windowUtils = this.window + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + windowUtils.resumeTimeouts(); + windowUtils.suppressEventHandling(false); + }, + + _changeTopLevelDocument(window) { + // Fake a will-navigate on the previous document + // to let a chance to unregister it + this._willNavigate(this.window, window.location.href, null, true); + + this._windowDestroyed(this.window, null, true); + + // Immediately change the window as this window, if in process of unload + // may already be non working on the next cycle and start throwing + this._setWindow(window); + + DevToolsUtils.executeSoon(() => { + // Then fake window-ready and navigate on the given document + this._windowReady(window, true); + DevToolsUtils.executeSoon(() => { + this._navigate(window, true); + }); + }); + }, + + _setWindow(window) { + let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + // Here is the very important call where we switch the currently + // targeted context (it will indirectly update this.window and + // many other attributes defined from docShell). + Object.defineProperty(this, "docShell", { + value: docShell, + enumerable: true, + configurable: true + }); + events.emit(this, "changed-toplevel-document"); + this.conn.send({ + from: this.actorID, + type: "frameUpdate", + selected: this.outerWindowID + }); + }, + + /** + * Handle location changes, by clearing the previous debuggees and enabling + * debugging, which may have been disabled temporarily by the + * DebuggerProgressListener. + */ + _windowReady(window, isFrameSwitching = false) { + let isTopLevel = window == this.window; + + // We just reset iframe list on WillNavigate, so we now list all existing + // frames when we load a new document in the original window + if (window == this._originalWindow && !isFrameSwitching) { + this._updateChildDocShells(); + } + + events.emit(this, "window-ready", { + window: window, + isTopLevel: isTopLevel, + id: getWindowID(window) + }); + + // TODO bug 997119: move that code to ThreadActor by listening to + // window-ready + let threadActor = this.threadActor; + if (isTopLevel && threadActor.state != "detached") { + this.sources.reset({ sourceMaps: true }); + threadActor.clearDebuggees(); + threadActor.dbg.enabled = true; + threadActor.maybePauseOnExceptions(); + // Update the global no matter if the debugger is on or off, + // otherwise the global will be wrong when enabled later. + threadActor.global = window; + } + + // Refresh the debuggee list when a new window object appears (top window or + // iframe). + if (threadActor.attached) { + threadActor.dbg.addDebuggees(); + } + }, + + _windowDestroyed(window, id = null, isFrozen = false) { + events.emit(this, "window-destroyed", { + window: window, + isTopLevel: window == this.window, + id: id || getWindowID(window), + isFrozen: isFrozen + }); + }, + + /** + * Start notifying server and client about a new document + * being loaded in the currently targeted context. + */ + _willNavigate(window, newURI, request, isFrameSwitching = false) { + let isTopLevel = window == this.window; + let reset = false; + + if (window == this._originalWindow && !isFrameSwitching) { + // Clear the iframe list if the original top-level document changes. + this._notifyDocShellDestroyAll(); + + // If the top level document changes and we are targeting + // an iframe, we need to reset to the upcoming new top level document. + // But for this will-navigate event, we will dispatch on the old window. + // (The inspector codebase expect to receive will-navigate for the + // currently displayed document in order to cleanup the markup view) + if (this.window != this._originalWindow) { + reset = true; + window = this.window; + isTopLevel = true; + } + } + + // will-navigate event needs to be dispatched synchronously, + // by calling the listeners in the order or registration. + // This event fires once navigation starts, + // (all pending user prompts are dealt with), + // but before the first request starts. + events.emit(this, "will-navigate", { + window: window, + isTopLevel: isTopLevel, + newURI: newURI, + request: request + }); + + // We don't do anything for inner frames in TabActor. + // (we will only update thread actor on window-ready) + if (!isTopLevel) { + return; + } + + // Proceed normally only if the debuggee is not paused. + // TODO bug 997119: move that code to ThreadActor by listening to + // will-navigate + let threadActor = this.threadActor; + if (threadActor.state == "paused") { + this.conn.send( + threadActor.unsafeSynchronize(Promise.resolve(threadActor.onResume()))); + threadActor.dbg.enabled = false; + } + threadActor.disableAllBreakpoints(); + + this.conn.send({ + from: this.actorID, + type: "tabNavigated", + url: newURI, + nativeConsoleAPI: true, + state: "start", + isFrameSwitching: isFrameSwitching + }); + + if (reset) { + this._setWindow(this._originalWindow); + } + }, + + /** + * Notify server and client about a new document done loading in the current + * targeted context. + */ + _navigate(window, isFrameSwitching = false) { + let isTopLevel = window == this.window; + + // navigate event needs to be dispatched synchronously, + // by calling the listeners in the order or registration. + // This event is fired once the document is loaded, + // after the load event, it's document ready-state is 'complete'. + events.emit(this, "navigate", { + window: window, + isTopLevel: isTopLevel + }); + + // We don't do anything for inner frames in TabActor. + // (we will only update thread actor on window-ready) + if (!isTopLevel) { + return; + } + + // TODO bug 997119: move that code to ThreadActor by listening to navigate + let threadActor = this.threadActor; + if (threadActor.state == "running") { + threadActor.dbg.enabled = true; + } + + this.conn.send({ + from: this.actorID, + type: "tabNavigated", + url: this.url, + title: this.title, + nativeConsoleAPI: this.hasNativeConsoleAPI(this.window), + state: "stop", + isFrameSwitching: isFrameSwitching + }); + }, + + /** + * Tells if the window.console object is native or overwritten by script in + * the page. + * + * @param nsIDOMWindow window + * The window object you want to check. + * @return boolean + * True if the window.console object is native, or false otherwise. + */ + hasNativeConsoleAPI(window) { + let isNative = false; + try { + // We are very explicitly examining the "console" property of + // the non-Xrayed object here. + let console = window.wrappedJSObject.console; + isNative = new XPCNativeWrapper(console).IS_NATIVE_CONSOLE; + } catch (ex) { + // ignore + } + return isNative; + }, + + /** + * Create or return the StyleSheetActor for a style sheet. This method + * is here because the Style Editor and Inspector share style sheet actors. + * + * @param DOMStyleSheet styleSheet + * The style sheet to create an actor for. + * @return StyleSheetActor actor + * The actor for this style sheet. + * + */ + createStyleSheetActor(styleSheet) { + if (this._styleSheetActors.has(styleSheet)) { + return this._styleSheetActors.get(styleSheet); + } + let actor = new StyleSheetActor(styleSheet, this); + this._styleSheetActors.set(styleSheet, actor); + + this._tabPool.addActor(actor); + events.emit(this, "stylesheet-added", actor); + + return actor; + }, + + removeActorByName(name) { + if (name in this._extraActors) { + const actor = this._extraActors[name]; + if (this._tabActorPool.has(actor)) { + this._tabActorPool.removeActor(actor); + } + delete this._extraActors[name]; + } + }, + + /** + * Takes a packet containing a url, line and column and returns + * the updated url, line and column based on the current source mapping + * (source mapped files, pretty prints). + * + * @param {String} request.url + * @param {Number} request.line + * @param {Number?} request.column + * @return {Promise<Object>} + */ + onResolveLocation(request) { + let { url, line } = request; + let column = request.column || 0; + const scripts = this.threadActor.dbg.findScripts({ url }); + + if (!scripts[0] || !scripts[0].source) { + return promise.resolve({ + from: this.actorID, + type: "resolveLocation", + error: "SOURCE_NOT_FOUND" + }); + } + const source = scripts[0].source; + const generatedActor = this.sources.createNonSourceMappedActor(source); + let generatedLocation = new GeneratedLocation( + generatedActor, line, column); + return this.sources.getOriginalLocation(generatedLocation).then(loc => { + // If no map found, return this packet + if (loc.originalLine == null) { + return { + type: "resolveLocation", + error: "MAP_NOT_FOUND" + }; + } + + loc = loc.toJSON(); + return { + from: this.actorID, + url: loc.source.url, + column: loc.column, + line: loc.line + }; + }); + }, +}; + +/** + * The request types this actor can handle. + */ +TabActor.prototype.requestTypes = { + "attach": TabActor.prototype.onAttach, + "detach": TabActor.prototype.onDetach, + "focus": TabActor.prototype.onFocus, + "reload": TabActor.prototype.onReload, + "navigateTo": TabActor.prototype.onNavigateTo, + "reconfigure": TabActor.prototype.onReconfigure, + "switchToFrame": TabActor.prototype.onSwitchToFrame, + "listFrames": TabActor.prototype.onListFrames, + "listWorkers": TabActor.prototype.onListWorkers, + "resolveLocation": TabActor.prototype.onResolveLocation +}; + +exports.TabActor = TabActor; + +/** + * Creates a tab actor for handling requests to a single browser frame. + * Both <xul:browser> and <iframe mozbrowser> are supported. + * This actor is a shim that connects to a ContentActor in a remote browser process. + * All RDP packets get forwarded using the message manager. + * + * @param connection The main RDP connection. + * @param browser <xul:browser> or <iframe mozbrowser> element to connect to. + */ +function BrowserTabActor(connection, browser) { + this._conn = connection; + this._browser = browser; + this._form = null; +} + +BrowserTabActor.prototype = { + connect() { + let onDestroy = () => { + this._form = null; + }; + let connect = DebuggerServer.connectToChild(this._conn, this._browser, onDestroy); + return connect.then(form => { + this._form = form; + return this; + }); + }, + + get _tabbrowser() { + if (typeof this._browser.getTabBrowser == "function") { + return this._browser.getTabBrowser(); + } + return null; + }, + + get _mm() { + // Get messageManager from XUL browser (which might be a specialized tunnel for RDM) + // or else fallback to asking the frameLoader itself. + return this._browser.messageManager || + this._browser.frameLoader.messageManager; + }, + + update() { + // If the child happens to be crashed/close/detach, it won't have _form set, + // so only request form update if some code is still listening on the other + // side. + if (this._form) { + let deferred = promise.defer(); + let onFormUpdate = msg => { + // There may be more than just one childtab.js up and running + if (this._form.actor != msg.json.actor) { + return; + } + this._mm.removeMessageListener("debug:form", onFormUpdate); + this._form = msg.json; + deferred.resolve(this); + }; + this._mm.addMessageListener("debug:form", onFormUpdate); + this._mm.sendAsyncMessage("debug:form"); + return deferred.promise; + } + + return this.connect(); + }, + + /** + * If we don't have a title from the content side because it's a zombie tab, try to find + * it on the chrome side. + */ + get title() { + // On Fennec, we can check the session store data for zombie tabs + if (this._browser.__SS_restore) { + let sessionStore = this._browser.__SS_data; + // Get the last selected entry + let entry = sessionStore.entries[sessionStore.index - 1]; + return entry.title; + } + // If contentTitle is empty (e.g. on a not-yet-restored tab), but there is a + // tabbrowser (i.e. desktop Firefox, but not Fennec), we can use the label + // as the title. + if (this._tabbrowser) { + let tab = this._tabbrowser.getTabForBrowser(this._browser); + if (tab) { + return tab.label; + } + } + return ""; + }, + + /** + * If we don't have a url from the content side because it's a zombie tab, try to find + * it on the chrome side. + */ + get url() { + // On Fennec, we can check the session store data for zombie tabs + if (this._browser.__SS_restore) { + let sessionStore = this._browser.__SS_data; + // Get the last selected entry + let entry = sessionStore.entries[sessionStore.index - 1]; + return entry.url; + } + return null; + }, + + form() { + let form = Object.assign({}, this._form); + // In some cases, the title and url fields might be empty. Zombie tabs (not yet + // restored) are a good example. In such cases, try to look up values for these + // fields using other data in the parent process. + if (!form.title) { + form.title = this.title; + } + if (!form.url) { + form.url = this.url; + } + return form; + }, + + exit() { + this._browser = null; + }, +}; + +exports.BrowserTabActor = BrowserTabActor; + +function BrowserAddonList(connection) { + this._connection = connection; + this._actorByAddonId = new Map(); + this._onListChanged = null; +} + +BrowserAddonList.prototype.getList = function () { + let deferred = promise.defer(); + AddonManager.getAllAddons((addons) => { + for (let addon of addons) { + let actor = this._actorByAddonId.get(addon.id); + if (!actor) { + if (addon.isWebExtension) { + actor = new WebExtensionActor(this._connection, addon); + } else { + actor = new BrowserAddonActor(this._connection, addon); + } + + this._actorByAddonId.set(addon.id, actor); + } + } + deferred.resolve([...this._actorByAddonId].map(([_, actor]) => actor)); + }); + return deferred.promise; +}; + +Object.defineProperty(BrowserAddonList.prototype, "onListChanged", { + enumerable: true, + configurable: true, + get() { + return this._onListChanged; + }, + set(v) { + if (v !== null && typeof v != "function") { + throw new Error( + "onListChanged property may only be set to 'null' or a function"); + } + this._onListChanged = v; + this._adjustListener(); + } +}); + +BrowserAddonList.prototype.onInstalled = function (addon) { + this._notifyListChanged(); + this._adjustListener(); +}; + +BrowserAddonList.prototype.onUninstalled = function (addon) { + this._actorByAddonId.delete(addon.id); + this._notifyListChanged(); + this._adjustListener(); +}; + +BrowserAddonList.prototype._notifyListChanged = function () { + if (this._onListChanged) { + this._onListChanged(); + } +}; + +BrowserAddonList.prototype._adjustListener = function () { + if (this._onListChanged) { + // As long as the callback exists, we need to listen for changes + // so we can notify about add-on changes. + AddonManager.addAddonListener(this); + } else if (this._actorByAddonId.size === 0) { + // When the callback does not exist, we only need to keep listening + // if the actor cache will need adjusting when add-ons change. + AddonManager.removeAddonListener(this); + } +}; + +exports.BrowserAddonList = BrowserAddonList; + +/** + * The DebuggerProgressListener object is an nsIWebProgressListener which + * handles onStateChange events for the inspected browser. If the user tries to + * navigate away from a paused page, the listener makes sure that the debuggee + * is resumed before the navigation begins. + * + * @param TabActor aTabActor + * The tab actor associated with this listener. + */ +function DebuggerProgressListener(tabActor) { + this._tabActor = tabActor; + this._onWindowCreated = this.onWindowCreated.bind(this); + this._onWindowHidden = this.onWindowHidden.bind(this); + + // Watch for windows destroyed (global observer that will need filtering) + Services.obs.addObserver(this, "inner-window-destroyed", false); + + // XXX: for now we maintain the list of windows we know about in this instance + // so that we can discriminate windows we care about when observing + // inner-window-destroyed events. Bug 1016952 would remove the need for this. + this._knownWindowIDs = new Map(); + + this._watchedDocShells = new WeakSet(); +} + +DebuggerProgressListener.prototype = { + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference, + Ci.nsISupports, + ]), + + destroy() { + Services.obs.removeObserver(this, "inner-window-destroyed", false); + this._knownWindowIDs.clear(); + this._knownWindowIDs = null; + }, + + watch(docShell) { + // Add the docshell to the watched set. We're actually adding the window, + // because docShell objects are not wrappercached and would be rejected + // by the WeakSet. + let docShellWindow = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + this._watchedDocShells.add(docShellWindow); + + let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener(this, + Ci.nsIWebProgress.NOTIFY_STATUS | + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW | + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT); + + let handler = getDocShellChromeEventHandler(docShell); + handler.addEventListener("DOMWindowCreated", this._onWindowCreated, true); + handler.addEventListener("pageshow", this._onWindowCreated, true); + handler.addEventListener("pagehide", this._onWindowHidden, true); + + // Dispatch the _windowReady event on the tabActor for pre-existing windows + for (let win of this._getWindowsInDocShell(docShell)) { + this._tabActor._windowReady(win); + this._knownWindowIDs.set(getWindowID(win), win); + } + }, + + unwatch(docShell) { + let docShellWindow = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + if (!this._watchedDocShells.has(docShellWindow)) { + return; + } + + let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + // During process shutdown, the docshell may already be cleaned up and throw + try { + webProgress.removeProgressListener(this); + } catch (e) { + // ignore + } + + let handler = getDocShellChromeEventHandler(docShell); + handler.removeEventListener("DOMWindowCreated", + this._onWindowCreated, true); + handler.removeEventListener("pageshow", this._onWindowCreated, true); + handler.removeEventListener("pagehide", this._onWindowHidden, true); + + for (let win of this._getWindowsInDocShell(docShell)) { + this._knownWindowIDs.delete(getWindowID(win)); + } + }, + + _getWindowsInDocShell(docShell) { + return getChildDocShells(docShell).map(d => { + return d.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + }); + }, + + onWindowCreated: DevToolsUtils.makeInfallible(function (evt) { + if (!this._tabActor.attached) { + return; + } + + // pageshow events for non-persisted pages have already been handled by a + // prior DOMWindowCreated event. For persisted pages, act as if the window + // had just been created since it's been unfrozen from bfcache. + if (evt.type == "pageshow" && !evt.persisted) { + return; + } + + let window = evt.target.defaultView; + this._tabActor._windowReady(window); + + if (evt.type !== "pageshow") { + this._knownWindowIDs.set(getWindowID(window), window); + } + }, "DebuggerProgressListener.prototype.onWindowCreated"), + + onWindowHidden: DevToolsUtils.makeInfallible(function (evt) { + if (!this._tabActor.attached) { + return; + } + + // Only act as if the window has been destroyed if the 'pagehide' event + // was sent for a persisted window (persisted is set when the page is put + // and frozen in the bfcache). If the page isn't persisted, the observer's + // inner-window-destroyed event will handle it. + if (!evt.persisted) { + return; + } + + let window = evt.target.defaultView; + this._tabActor._windowDestroyed(window, null, true); + }, "DebuggerProgressListener.prototype.onWindowHidden"), + + observe: DevToolsUtils.makeInfallible(function (subject, topic) { + if (!this._tabActor.attached) { + return; + } + + // Because this observer will be called for all inner-window-destroyed in + // the application, we need to filter out events for windows we are not + // watching + let innerID = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + let window = this._knownWindowIDs.get(innerID); + if (window) { + this._knownWindowIDs.delete(innerID); + this._tabActor._windowDestroyed(window, innerID); + } + }, "DebuggerProgressListener.prototype.observe"), + + onStateChange: + DevToolsUtils.makeInfallible(function (progress, request, flag, status) { + if (!this._tabActor.attached) { + return; + } + + let isStart = flag & Ci.nsIWebProgressListener.STATE_START; + let isStop = flag & Ci.nsIWebProgressListener.STATE_STOP; + let isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT; + let isWindow = flag & Ci.nsIWebProgressListener.STATE_IS_WINDOW; + + // Catch any iframe location change + if (isDocument && isStop) { + // Watch document stop to ensure having the new iframe url. + progress.QueryInterface(Ci.nsIDocShell); + this._tabActor._notifyDocShellsUpdate([progress]); + } + + let window = progress.DOMWindow; + if (isDocument && isStart) { + // One of the earliest events that tells us a new URI + // is being loaded in this window. + let newURI = request instanceof Ci.nsIChannel ? request.URI.spec : null; + this._tabActor._willNavigate(window, newURI, request); + } + if (isWindow && isStop) { + // Don't dispatch "navigate" event just yet when there is a redirect to + // about:neterror page. + if (request.status != Cr.NS_OK) { + // Instead, listen for DOMContentLoaded as about:neterror is loaded + // with LOAD_BACKGROUND flags and never dispatches load event. + // That may be the same reason why there is no onStateChange event + // for about:neterror loads. + let handler = getDocShellChromeEventHandler(progress); + let onLoad = evt => { + // Ignore events from iframes + if (evt.target == window.document) { + handler.removeEventListener("DOMContentLoaded", onLoad, true); + this._tabActor._navigate(window); + } + }; + handler.addEventListener("DOMContentLoaded", onLoad, true); + } else { + // Somewhat equivalent of load event. + // (window.document.readyState == complete) + this._tabActor._navigate(window); + } + } + }, "DebuggerProgressListener.prototype.onStateChange") +}; + +exports.register = function (handle) { + handle.setRootActor(createRootActor); +}; + +exports.unregister = function (handle) { + handle.setRootActor(null); +}; diff --git a/devtools/server/actors/webconsole.js b/devtools/server/actors/webconsole.js new file mode 100644 index 000000000..9712ff32d --- /dev/null +++ b/devtools/server/actors/webconsole.js @@ -0,0 +1,2346 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* vim: set 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 { Cc, Ci, Cu } = require("chrome"); +const { DebuggerServer, ActorPool } = require("devtools/server/main"); +const { EnvironmentActor } = require("devtools/server/actors/environment"); +const { ThreadActor } = require("devtools/server/actors/script"); +const { ObjectActor, LongStringActor, createValueGrip, stringIsLong } = require("devtools/server/actors/object"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const ErrorDocs = require("devtools/server/actors/errordocs"); + +loader.lazyRequireGetter(this, "NetworkMonitor", "devtools/shared/webconsole/network-monitor", true); +loader.lazyRequireGetter(this, "NetworkMonitorChild", "devtools/shared/webconsole/network-monitor", true); +loader.lazyRequireGetter(this, "ConsoleProgressListener", "devtools/shared/webconsole/network-monitor", true); +loader.lazyRequireGetter(this, "StackTraceCollector", "devtools/shared/webconsole/network-monitor", true); +loader.lazyRequireGetter(this, "events", "sdk/event/core"); +loader.lazyRequireGetter(this, "ServerLoggingListener", "devtools/shared/webconsole/server-logger", true); +loader.lazyRequireGetter(this, "JSPropertyProvider", "devtools/shared/webconsole/js-property-provider", true); +loader.lazyRequireGetter(this, "Parser", "resource://devtools/shared/Parser.jsm", true); +loader.lazyRequireGetter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm", true); + +for (let name of ["WebConsoleUtils", "ConsoleServiceListener", + "ConsoleAPIListener", "addWebConsoleCommands", + "ConsoleReflowListener", "CONSOLE_WORKER_IDS"]) { + Object.defineProperty(this, name, { + get: function (prop) { + if (prop == "WebConsoleUtils") { + prop = "Utils"; + } + if (isWorker) { + return require("devtools/server/actors/utils/webconsole-worker-utils")[prop]; + } else { + return require("devtools/server/actors/utils/webconsole-utils")[prop]; + } + }.bind(null, name), + configurable: true, + enumerable: true + }); +} + +/** + * The WebConsoleActor implements capabilities needed for the Web Console + * feature. + * + * @constructor + * @param object aConnection + * The connection to the client, DebuggerServerConnection. + * @param object [aParentActor] + * Optional, the parent actor. + */ +function WebConsoleActor(aConnection, aParentActor) +{ + this.conn = aConnection; + this.parentActor = aParentActor; + + this._actorPool = new ActorPool(this.conn); + this.conn.addActorPool(this._actorPool); + + this._prefs = {}; + + this.dbg = this.parentActor.makeDebugger(); + + this._netEvents = new Map(); + this._gripDepth = 0; + this._listeners = new Set(); + this._lastConsoleInputEvaluation = undefined; + + this.objectGrip = this.objectGrip.bind(this); + this._onWillNavigate = this._onWillNavigate.bind(this); + this._onChangedToplevelDocument = this._onChangedToplevelDocument.bind(this); + events.on(this.parentActor, "changed-toplevel-document", this._onChangedToplevelDocument); + this._onObserverNotification = this._onObserverNotification.bind(this); + if (this.parentActor.isRootActor) { + Services.obs.addObserver(this._onObserverNotification, + "last-pb-context-exited", false); + } + + this.traits = { + customNetworkRequest: !this._parentIsContentActor, + evaluateJSAsync: true, + transferredResponseSize: true, + selectedObjectActor: true, // 44+ + }; +} + +WebConsoleActor.prototype = +{ + /** + * Debugger instance. + * + * @see jsdebugger.jsm + */ + dbg: null, + + /** + * This is used by the ObjectActor to keep track of the depth of grip() calls. + * @private + * @type number + */ + _gripDepth: null, + + /** + * Actor pool for all of the actors we send to the client. + * @private + * @type object + * @see ActorPool + */ + _actorPool: null, + + /** + * Web Console-related preferences. + * @private + * @type object + */ + _prefs: null, + + /** + * Holds a map between nsIChannel objects and NetworkEventActors for requests + * created with sendHTTPRequest. + * + * @private + * @type Map + */ + _netEvents: null, + + /** + * Holds a set of all currently registered listeners. + * + * @private + * @type Set + */ + _listeners: null, + + /** + * The debugger server connection instance. + * @type object + */ + conn: null, + + /** + * List of supported features by the console actor. + * @type object + */ + traits: null, + + /** + * Boolean getter that tells if the parent actor is a ContentActor. + * + * @private + * @type boolean + */ + get _parentIsContentActor() { + return "ContentActor" in DebuggerServer && + this.parentActor instanceof DebuggerServer.ContentActor; + }, + + /** + * The window or sandbox we work with. + * Note that even if it is named `window` it refers to the current + * global we are debugging, which can be a Sandbox for addons + * or browser content toolbox. + * + * @type nsIDOMWindow or Sandbox + */ + get window() { + if (this.parentActor.isRootActor) { + return this._getWindowForBrowserConsole(); + } + return this.parentActor.window; + }, + + /** + * Get a window to use for the browser console. + * + * @private + * @return nsIDOMWindow + * The window to use, or null if no window could be found. + */ + _getWindowForBrowserConsole: function WCA__getWindowForBrowserConsole() + { + // Check if our last used chrome window is still live. + let window = this._lastChromeWindow && this._lastChromeWindow.get(); + // If not, look for a new one. + if (!window || window.closed) { + window = this.parentActor.window; + if (!window) { + // Try to find the Browser Console window to use instead. + window = Services.wm.getMostRecentWindow("devtools:webconsole"); + // We prefer the normal chrome window over the console window, + // so we'll look for those windows in order to replace our reference. + let onChromeWindowOpened = () => { + // We'll look for this window when someone next requests window() + Services.obs.removeObserver(onChromeWindowOpened, "domwindowopened"); + this._lastChromeWindow = null; + }; + Services.obs.addObserver(onChromeWindowOpened, "domwindowopened", false); + } + + this._handleNewWindow(window); + } + + return window; + }, + + /** + * Store a newly found window on the actor to be used in the future. + * + * @private + * @param nsIDOMWindow window + * The window to store on the actor (can be null). + */ + _handleNewWindow: function WCA__handleNewWindow(window) + { + if (window) { + if (this._hadChromeWindow) { + Services.console.logStringMessage('Webconsole context has changed'); + } + this._lastChromeWindow = Cu.getWeakReference(window); + this._hadChromeWindow = true; + } else { + this._lastChromeWindow = null; + } + }, + + /** + * Whether we've been using a window before. + * + * @private + * @type boolean + */ + _hadChromeWindow: false, + + /** + * A weak reference to the last chrome window we used to work with. + * + * @private + * @type nsIWeakReference + */ + _lastChromeWindow: null, + + // The evalWindow is used at the scope for JS evaluation. + _evalWindow: null, + get evalWindow() { + return this._evalWindow || this.window; + }, + + set evalWindow(aWindow) { + this._evalWindow = aWindow; + + if (!this._progressListenerActive) { + events.on(this.parentActor, "will-navigate", this._onWillNavigate); + this._progressListenerActive = true; + } + }, + + /** + * Flag used to track if we are listening for events from the progress + * listener of the tab actor. We use the progress listener to clear + * this.evalWindow on page navigation. + * + * @private + * @type boolean + */ + _progressListenerActive: false, + + /** + * The ConsoleServiceListener instance. + * @type object + */ + consoleServiceListener: null, + + /** + * The ConsoleAPIListener instance. + */ + consoleAPIListener: null, + + /** + * The NetworkMonitor instance. + */ + networkMonitor: null, + + /** + * The NetworkMonitor instance living in the same (child) process. + */ + networkMonitorChild: null, + + /** + * The ConsoleProgressListener instance. + */ + consoleProgressListener: null, + + /** + * The ConsoleReflowListener instance. + */ + consoleReflowListener: null, + + /** + * The Web Console Commands names cache. + * @private + * @type array + */ + _webConsoleCommandsCache: null, + + actorPrefix: "console", + + get globalDebugObject() { + return this.parentActor.threadActor.globalDebugObject; + }, + + grip: function WCA_grip() + { + return { actor: this.actorID }; + }, + + hasNativeConsoleAPI: function WCA_hasNativeConsoleAPI(aWindow) { + let isNative = false; + try { + // We are very explicitly examining the "console" property of + // the non-Xrayed object here. + let console = aWindow.wrappedJSObject.console; + isNative = new XPCNativeWrapper(console).IS_NATIVE_CONSOLE + } + catch (ex) { } + return isNative; + }, + + _findProtoChain: ThreadActor.prototype._findProtoChain, + _removeFromProtoChain: ThreadActor.prototype._removeFromProtoChain, + + /** + * Destroy the current WebConsoleActor instance. + */ + disconnect: function WCA_disconnect() + { + if (this.consoleServiceListener) { + this.consoleServiceListener.destroy(); + this.consoleServiceListener = null; + } + if (this.consoleAPIListener) { + this.consoleAPIListener.destroy(); + this.consoleAPIListener = null; + } + if (this.networkMonitor) { + this.networkMonitor.destroy(); + this.networkMonitor = null; + } + if (this.networkMonitorChild) { + this.networkMonitorChild.destroy(); + this.networkMonitorChild = null; + } + if (this.stackTraceCollector) { + this.stackTraceCollector.destroy(); + this.stackTraceCollector = null; + } + if (this.consoleProgressListener) { + this.consoleProgressListener.destroy(); + this.consoleProgressListener = null; + } + if (this.consoleReflowListener) { + this.consoleReflowListener.destroy(); + this.consoleReflowListener = null; + } + if (this.serverLoggingListener) { + this.serverLoggingListener.destroy(); + this.serverLoggingListener = null; + } + + events.off(this.parentActor, "changed-toplevel-document", + this._onChangedToplevelDocument); + + this.conn.removeActorPool(this._actorPool); + + if (this.parentActor.isRootActor) { + Services.obs.removeObserver(this._onObserverNotification, + "last-pb-context-exited"); + } + + this._actorPool = null; + this._webConsoleCommandsCache = null; + this._lastConsoleInputEvaluation = null; + this._evalWindow = null; + this._netEvents.clear(); + this.dbg.enabled = false; + this.dbg = null; + this.conn = null; + }, + + /** + * Create and return an environment actor that corresponds to the provided + * Debugger.Environment. This is a straightforward clone of the ThreadActor's + * method except that it stores the environment actor in the web console + * actor's pool. + * + * @param Debugger.Environment aEnvironment + * The lexical environment we want to extract. + * @return The EnvironmentActor for aEnvironment or undefined for host + * functions or functions scoped to a non-debuggee global. + */ + createEnvironmentActor: function WCA_createEnvironmentActor(aEnvironment) { + if (!aEnvironment) { + return undefined; + } + + if (aEnvironment.actor) { + return aEnvironment.actor; + } + + let actor = new EnvironmentActor(aEnvironment, this); + this._actorPool.addActor(actor); + aEnvironment.actor = actor; + + return actor; + }, + + /** + * Create a grip for the given value. + * + * @param mixed aValue + * @return object + */ + createValueGrip: function WCA_createValueGrip(aValue) + { + return createValueGrip(aValue, this._actorPool, this.objectGrip); + }, + + /** + * Make a debuggee value for the given value. + * + * @param mixed aValue + * The value you want to get a debuggee value for. + * @param boolean aUseObjectGlobal + * If |true| the object global is determined and added as a debuggee, + * otherwise |this.window| is used when makeDebuggeeValue() is invoked. + * @return object + * Debuggee value for |aValue|. + */ + makeDebuggeeValue: function WCA_makeDebuggeeValue(aValue, aUseObjectGlobal) + { + if (aUseObjectGlobal && typeof aValue == "object") { + try { + let global = Cu.getGlobalForObject(aValue); + let dbgGlobal = this.dbg.makeGlobalObjectReference(global); + return dbgGlobal.makeDebuggeeValue(aValue); + } + catch (ex) { + // The above can throw an exception if aValue is not an actual object + // or 'Object in compartment marked as invisible to Debugger' + } + } + let dbgGlobal = this.dbg.makeGlobalObjectReference(this.window); + return dbgGlobal.makeDebuggeeValue(aValue); + }, + + /** + * Create a grip for the given object. + * + * @param object aObject + * The object you want. + * @param object aPool + * An ActorPool where the new actor instance is added. + * @param object + * The object grip. + */ + objectGrip: function WCA_objectGrip(aObject, aPool) + { + let actor = new ObjectActor(aObject, { + getGripDepth: () => this._gripDepth, + incrementGripDepth: () => this._gripDepth++, + decrementGripDepth: () => this._gripDepth--, + createValueGrip: v => this.createValueGrip(v), + sources: () => DevToolsUtils.reportException("WebConsoleActor", + Error("sources not yet implemented")), + createEnvironmentActor: (env) => this.createEnvironmentActor(env), + getGlobalDebugObject: () => this.globalDebugObject + }); + aPool.addActor(actor); + return actor.grip(); + }, + + /** + * Create a grip for the given string. + * + * @param string aString + * The string you want to create the grip for. + * @param object aPool + * An ActorPool where the new actor instance is added. + * @return object + * A LongStringActor object that wraps the given string. + */ + longStringGrip: function WCA_longStringGrip(aString, aPool) + { + let actor = new LongStringActor(aString); + aPool.addActor(actor); + return actor.grip(); + }, + + /** + * Create a long string grip if needed for the given string. + * + * @private + * @param string aString + * The string you want to create a long string grip for. + * @return string|object + * A string is returned if |aString| is not a long string. + * A LongStringActor grip is returned if |aString| is a long string. + */ + _createStringGrip: function NEA__createStringGrip(aString) + { + if (aString && stringIsLong(aString)) { + return this.longStringGrip(aString, this._actorPool); + } + return aString; + }, + + /** + * Get an object actor by its ID. + * + * @param string aActorID + * @return object + */ + getActorByID: function WCA_getActorByID(aActorID) + { + return this._actorPool.get(aActorID); + }, + + /** + * Release an actor. + * + * @param object aActor + * The actor instance you want to release. + */ + releaseActor: function WCA_releaseActor(aActor) + { + this._actorPool.removeActor(aActor.actorID); + }, + + /** + * Returns the latest web console input evaluation. + * This is undefined if no evaluations have been completed. + * + * @return object + */ + getLastConsoleInputEvaluation: function WCU_getLastConsoleInputEvaluation() + { + return this._lastConsoleInputEvaluation; + }, + + // Request handlers for known packet types. + + /** + * Handler for the "startListeners" request. + * + * @param object aRequest + * The JSON request object received from the Web Console client. + * @return object + * The response object which holds the startedListeners array. + */ + onStartListeners: function WCA_onStartListeners(aRequest) + { + // XXXworkers: Not handling the Console API yet for workers (Bug 1209353). + if (isWorker) { + aRequest.listeners = []; + } + + let startedListeners = []; + let window = !this.parentActor.isRootActor ? this.window : null; + let appId = null; + let messageManager = null; + + if (this._parentIsContentActor) { + appId = this.parentActor.docShell.appId; + messageManager = this.parentActor.messageManager; + } + + while (aRequest.listeners.length > 0) { + let listener = aRequest.listeners.shift(); + switch (listener) { + case "PageError": + if (!this.consoleServiceListener) { + this.consoleServiceListener = + new ConsoleServiceListener(window, this); + this.consoleServiceListener.init(); + } + startedListeners.push(listener); + break; + case "ConsoleAPI": + if (!this.consoleAPIListener) { + // Create the consoleAPIListener (and apply the filtering options defined + // in the parent actor). + this.consoleAPIListener = + new ConsoleAPIListener(window, this, + this.parentActor.consoleAPIListenerOptions); + this.consoleAPIListener.init(); + } + startedListeners.push(listener); + break; + case "NetworkActivity": + if (!this.networkMonitor) { + // Create a StackTraceCollector that's going to be shared both by the + // NetworkMonitorChild (getting messages about requests from parent) and + // by the NetworkMonitor that directly watches service workers requests. + this.stackTraceCollector = new StackTraceCollector({ window, appId }); + this.stackTraceCollector.init(); + + let processBoundary = Services.appinfo.processType != + Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; + if ((appId || messageManager) && processBoundary) { + // Start a network monitor in the parent process to listen to + // most requests than happen in parent + this.networkMonitor = + new NetworkMonitorChild(appId, this.parentActor.outerWindowID, + messageManager, this.conn, this); + this.networkMonitor.init(); + // Spawn also one in the child to listen to service workers + this.networkMonitorChild = new NetworkMonitor({ window }, this); + this.networkMonitorChild.init(); + } else { + this.networkMonitor = new NetworkMonitor({ window }, this); + this.networkMonitor.init(); + } + } + startedListeners.push(listener); + break; + case "FileActivity": + if (this.window instanceof Ci.nsIDOMWindow) { + if (!this.consoleProgressListener) { + this.consoleProgressListener = + new ConsoleProgressListener(this.window, this); + } + this.consoleProgressListener.startMonitor(this.consoleProgressListener. + MONITOR_FILE_ACTIVITY); + startedListeners.push(listener); + } + break; + case "ReflowActivity": + if (!this.consoleReflowListener) { + this.consoleReflowListener = + new ConsoleReflowListener(this.window, this); + } + startedListeners.push(listener); + break; + case "ServerLogging": + if (!this.serverLoggingListener) { + this.serverLoggingListener = + new ServerLoggingListener(this.window, this); + } + startedListeners.push(listener); + break; + } + } + + // Update the live list of running listeners + startedListeners.forEach(this._listeners.add, this._listeners); + + return { + startedListeners: startedListeners, + nativeConsoleAPI: this.hasNativeConsoleAPI(this.window), + traits: this.traits, + }; + }, + + /** + * Handler for the "stopListeners" request. + * + * @param object aRequest + * The JSON request object received from the Web Console client. + * @return object + * The response packet to send to the client: holds the + * stoppedListeners array. + */ + onStopListeners: function WCA_onStopListeners(aRequest) + { + let stoppedListeners = []; + + // If no specific listeners are requested to be detached, we stop all + // listeners. + let toDetach = aRequest.listeners || + ["PageError", "ConsoleAPI", "NetworkActivity", + "FileActivity", "ServerLogging"]; + + while (toDetach.length > 0) { + let listener = toDetach.shift(); + switch (listener) { + case "PageError": + if (this.consoleServiceListener) { + this.consoleServiceListener.destroy(); + this.consoleServiceListener = null; + } + stoppedListeners.push(listener); + break; + case "ConsoleAPI": + if (this.consoleAPIListener) { + this.consoleAPIListener.destroy(); + this.consoleAPIListener = null; + } + stoppedListeners.push(listener); + break; + case "NetworkActivity": + if (this.networkMonitor) { + this.networkMonitor.destroy(); + this.networkMonitor = null; + } + if (this.networkMonitorChild) { + this.networkMonitorChild.destroy(); + this.networkMonitorChild = null; + } + if (this.stackTraceCollector) { + this.stackTraceCollector.destroy(); + this.stackTraceCollector = null; + } + stoppedListeners.push(listener); + break; + case "FileActivity": + if (this.consoleProgressListener) { + this.consoleProgressListener.stopMonitor(this.consoleProgressListener. + MONITOR_FILE_ACTIVITY); + this.consoleProgressListener = null; + } + stoppedListeners.push(listener); + break; + case "ReflowActivity": + if (this.consoleReflowListener) { + this.consoleReflowListener.destroy(); + this.consoleReflowListener = null; + } + stoppedListeners.push(listener); + break; + case "ServerLogging": + if (this.serverLoggingListener) { + this.serverLoggingListener.destroy(); + this.serverLoggingListener = null; + } + stoppedListeners.push(listener); + break; + } + } + + // Update the live list of running listeners + stoppedListeners.forEach(this._listeners.delete, this._listeners); + + return { stoppedListeners: stoppedListeners }; + }, + + /** + * Handler for the "getCachedMessages" request. This method sends the cached + * error messages and the window.console API calls to the client. + * + * @param object aRequest + * The JSON request object received from the Web Console client. + * @return object + * The response packet to send to the client: it holds the cached + * messages array. + */ + onGetCachedMessages: function WCA_onGetCachedMessages(aRequest) + { + let types = aRequest.messageTypes; + if (!types) { + return { + error: "missingParameter", + message: "The messageTypes parameter is missing.", + }; + } + + let messages = []; + + while (types.length > 0) { + let type = types.shift(); + switch (type) { + case "ConsoleAPI": { + if (!this.consoleAPIListener) { + break; + } + + // See `window` definition. It isn't always a DOM Window. + let requestStartTime = this.window && this.window.performance ? + this.window.performance.timing.requestStart : 0; + + let cache = this.consoleAPIListener + .getCachedMessages(!this.parentActor.isRootActor); + cache.forEach((aMessage) => { + // Filter out messages that came from a ServiceWorker but happened + // before the page was requested. + if (aMessage.innerID === "ServiceWorker" && + requestStartTime > aMessage.timeStamp) { + return; + } + + let message = this.prepareConsoleMessageForRemote(aMessage); + message._type = type; + messages.push(message); + }); + break; + } + case "PageError": { + if (!this.consoleServiceListener) { + break; + } + let cache = this.consoleServiceListener + .getCachedMessages(!this.parentActor.isRootActor); + cache.forEach((aMessage) => { + let message = null; + if (aMessage instanceof Ci.nsIScriptError) { + message = this.preparePageErrorForRemote(aMessage); + message._type = type; + } + else { + message = { + _type: "LogMessage", + message: this._createStringGrip(aMessage.message), + timeStamp: aMessage.timeStamp, + }; + } + messages.push(message); + }); + break; + } + } + } + + return { + from: this.actorID, + messages: messages, + }; + }, + + /** + * Handler for the "evaluateJSAsync" request. This method evaluates the given + * JavaScript string and sends back a packet with a unique ID. + * The result will be returned later as an unsolicited `evaluationResult`, + * that can be associated back to this request via the `resultID` field. + * + * @param object aRequest + * The JSON request object received from the Web Console client. + * @return object + * The response packet to send to with the unique id in the + * `resultID` field. + */ + onEvaluateJSAsync: function WCA_onEvaluateJSAsync(aRequest) + { + // We want to be able to run console commands without waiting + // for the first to return (see Bug 1088861). + + // First, send a response packet with the id only. + let resultID = Date.now(); + this.conn.send({ + from: this.actorID, + resultID: resultID + }); + + // Then, execute the script that may pause. + let response = this.onEvaluateJS(aRequest); + response.resultID = resultID; + + // Finally, send an unsolicited evaluationResult packet with + // the normal return value + this.conn.sendActorEvent(this.actorID, "evaluationResult", response); + }, + + /** + * Handler for the "evaluateJS" request. This method evaluates the given + * JavaScript string and sends back the result. + * + * @param object aRequest + * The JSON request object received from the Web Console client. + * @return object + * The evaluation response packet. + */ + onEvaluateJS: function WCA_onEvaluateJS(aRequest) + { + let input = aRequest.text; + let timestamp = Date.now(); + + let evalOptions = { + bindObjectActor: aRequest.bindObjectActor, + frameActor: aRequest.frameActor, + url: aRequest.url, + selectedNodeActor: aRequest.selectedNodeActor, + selectedObjectActor: aRequest.selectedObjectActor, + }; + + let evalInfo = this.evalWithDebugger(input, evalOptions); + let evalResult = evalInfo.result; + let helperResult = evalInfo.helperResult; + + let result, errorDocURL, errorMessage, errorGrip = null, frame = null; + if (evalResult) { + if ("return" in evalResult) { + result = evalResult.return; + } else if ("yield" in evalResult) { + result = evalResult.yield; + } else if ("throw" in evalResult) { + let error = evalResult.throw; + + errorGrip = this.createValueGrip(error); + + errorMessage = String(error); + if (typeof error === "object" && error !== null) { + try { + errorMessage = DevToolsUtils.callPropertyOnObject(error, "toString"); + } catch (e) { + // If the debuggee is not allowed to access the "toString" property + // of the error object, calling this property from the debuggee's + // compartment will fail. The debugger should show the error object + // as it is seen by the debuggee, so this behavior is correct. + // + // Unfortunately, we have at least one test that assumes calling the + // "toString" property of an error object will succeed if the + // debugger is allowed to access it, regardless of whether the + // debuggee is allowed to access it or not. + // + // To accomodate these tests, if calling the "toString" property + // from the debuggee compartment fails, we rewrap the error object + // in the debugger's compartment, and then call the "toString" + // property from there. + if (typeof error.unsafeDereference === "function") { + errorMessage = error.unsafeDereference().toString(); + } + } + } + + // It is possible that we won't have permission to unwrap an + // object and retrieve its errorMessageName. + try { + errorDocURL = ErrorDocs.GetURL(error); + } catch (ex) {} + + try { + let line = error.errorLineNumber; + let column = error.errorColumnNumber; + + if (typeof line === "number" && typeof column === "number") { + // Set frame only if we have line/column numbers. + frame = { + source: "debugger eval code", + line, + column + }; + } + } catch (ex) {} + } + } + + // If a value is encountered that the debugger server doesn't support yet, + // the console should remain functional. + let resultGrip; + try { + resultGrip = this.createValueGrip(result); + } catch (e) { + errorMessage = e; + } + + this._lastConsoleInputEvaluation = result; + + return { + from: this.actorID, + input: input, + result: resultGrip, + timestamp: timestamp, + exception: errorGrip, + exceptionMessage: this._createStringGrip(errorMessage), + exceptionDocURL: errorDocURL, + frame, + helperResult: helperResult, + }; + }, + + /** + * The Autocomplete request handler. + * + * @param object aRequest + * The request message - what input to autocomplete. + * @return object + * The response message - matched properties. + */ + onAutocomplete: function WCA_onAutocomplete(aRequest) + { + let frameActorId = aRequest.frameActor; + let dbgObject = null; + let environment = null; + let hadDebuggee = false; + + // This is the case of the paused debugger + if (frameActorId) { + let frameActor = this.conn.getActor(frameActorId); + try { + // Need to try/catch since accessing frame.environment + // can throw "Debugger.Frame is not live" + let frame = frameActor.frame; + environment = frame.environment; + } catch (e) { + DevToolsUtils.reportException("onAutocomplete", + Error("The frame actor was not found: " + frameActorId)); + } + } + // This is the general case (non-paused debugger) + else { + hadDebuggee = this.dbg.hasDebuggee(this.evalWindow); + dbgObject = this.dbg.addDebuggee(this.evalWindow); + } + + let result = JSPropertyProvider(dbgObject, environment, aRequest.text, + aRequest.cursor, frameActorId) || {}; + + if (!hadDebuggee && dbgObject) { + this.dbg.removeDebuggee(this.evalWindow); + } + + let matches = result.matches || []; + let reqText = aRequest.text.substr(0, aRequest.cursor); + + // We consider '$' as alphanumerc because it is used in the names of some + // helper functions. + let lastNonAlphaIsDot = /[.][a-zA-Z0-9$]*$/.test(reqText); + if (!lastNonAlphaIsDot) { + if (!this._webConsoleCommandsCache) { + let helpers = { + sandbox: Object.create(null) + }; + addWebConsoleCommands(helpers); + this._webConsoleCommandsCache = + Object.getOwnPropertyNames(helpers.sandbox); + } + matches = matches.concat(this._webConsoleCommandsCache + .filter(n => n.startsWith(result.matchProp))); + } + + return { + from: this.actorID, + matches: matches.sort(), + matchProp: result.matchProp, + }; + }, + + /** + * The "clearMessagesCache" request handler. + */ + onClearMessagesCache: function WCA_onClearMessagesCache() + { + // TODO: Bug 717611 - Web Console clear button does not clear cached errors + let windowId = !this.parentActor.isRootActor ? + WebConsoleUtils.getInnerWindowId(this.window) : null; + let ConsoleAPIStorage = Cc["@mozilla.org/consoleAPI-storage;1"] + .getService(Ci.nsIConsoleAPIStorage); + ConsoleAPIStorage.clearEvents(windowId); + + CONSOLE_WORKER_IDS.forEach((aId) => { + ConsoleAPIStorage.clearEvents(aId); + }); + + if (this.parentActor.isRootActor) { + Services.console.logStringMessage(null); // for the Error Console + Services.console.reset(); + } + return {}; + }, + + /** + * The "getPreferences" request handler. + * + * @param object aRequest + * The request message - which preferences need to be retrieved. + * @return object + * The response message - a { key: value } object map. + */ + onGetPreferences: function WCA_onGetPreferences(aRequest) + { + let prefs = Object.create(null); + for (let key of aRequest.preferences) { + prefs[key] = this._prefs[key]; + } + return { preferences: prefs }; + }, + + /** + * The "setPreferences" request handler. + * + * @param object aRequest + * The request message - which preferences need to be updated. + */ + onSetPreferences: function WCA_onSetPreferences(aRequest) + { + for (let key in aRequest.preferences) { + this._prefs[key] = aRequest.preferences[key]; + + if (this.networkMonitor) { + if (key == "NetworkMonitor.saveRequestAndResponseBodies") { + this.networkMonitor.saveRequestAndResponseBodies = this._prefs[key]; + if (this.networkMonitorChild) { + this.networkMonitorChild.saveRequestAndResponseBodies = + this._prefs[key]; + } + } else if (key == "NetworkMonitor.throttleData") { + this.networkMonitor.throttleData = this._prefs[key]; + if (this.networkMonitorChild) { + this.networkMonitorChild.throttleData = this._prefs[key]; + } + } + } + } + return { updated: Object.keys(aRequest.preferences) }; + }, + + // End of request handlers. + + /** + * Create an object with the API we expose to the Web Console during + * JavaScript evaluation. + * This object inherits properties and methods from the Web Console actor. + * + * @private + * @param object aDebuggerGlobal + * A Debugger.Object that wraps a content global. This is used for the + * Web Console Commands. + * @return object + * The same object as |this|, but with an added |sandbox| property. + * The sandbox holds methods and properties that can be used as + * bindings during JS evaluation. + */ + _getWebConsoleCommands: function (aDebuggerGlobal) + { + let helpers = { + window: this.evalWindow, + chromeWindow: this.chromeWindow.bind(this), + makeDebuggeeValue: aDebuggerGlobal.makeDebuggeeValue.bind(aDebuggerGlobal), + createValueGrip: this.createValueGrip.bind(this), + sandbox: Object.create(null), + helperResult: null, + consoleActor: this, + }; + addWebConsoleCommands(helpers); + + let evalWindow = this.evalWindow; + function maybeExport(obj, name) { + if (typeof obj[name] != "function") { + return; + } + + // By default, chrome-implemented functions that are exposed to content + // refuse to accept arguments that are cross-origin for the caller. This + // is generally the safe thing, but causes problems for certain console + // helpers like cd(), where we users sometimes want to pass a cross-origin + // window. To circumvent this restriction, we use exportFunction along + // with a special option designed for this purpose. See bug 1051224. + obj[name] = + Cu.exportFunction(obj[name], evalWindow, { allowCrossOriginArguments: true }); + } + for (let name in helpers.sandbox) { + let desc = Object.getOwnPropertyDescriptor(helpers.sandbox, name); + + // Workers don't have access to Cu so won't be able to exportFunction. + if (!isWorker) { + maybeExport(desc, "get"); + maybeExport(desc, "set"); + maybeExport(desc, "value"); + } + if (desc.value) { + // Make sure the helpers can be used during eval. + desc.value = aDebuggerGlobal.makeDebuggeeValue(desc.value); + } + Object.defineProperty(helpers.sandbox, name, desc); + } + return helpers; + }, + + /** + * Evaluates a string using the debugger API. + * + * To allow the variables view to update properties from the Web Console we + * provide the "bindObjectActor" mechanism: the Web Console tells the + * ObjectActor ID for which it desires to evaluate an expression. The + * Debugger.Object pointed at by the actor ID is bound such that it is + * available during expression evaluation (executeInGlobalWithBindings()). + * + * Example: + * _self['foobar'] = 'test' + * where |_self| refers to the desired object. + * + * The |frameActor| property allows the Web Console client to provide the + * frame actor ID, such that the expression can be evaluated in the + * user-selected stack frame. + * + * For the above to work we need the debugger and the Web Console to share + * a connection, otherwise the Web Console actor will not find the frame + * actor. + * + * The Debugger.Frame comes from the jsdebugger's Debugger instance, which + * is different from the Web Console's Debugger instance. This means that + * for evaluation to work, we need to create a new instance for the Web + * Console Commands helpers - they need to be Debugger.Objects coming from the + * jsdebugger's Debugger instance. + * + * When |bindObjectActor| is used objects can come from different iframes, + * from different domains. To avoid permission-related errors when objects + * come from a different window, we also determine the object's own global, + * such that evaluation happens in the context of that global. This means that + * evaluation will happen in the object's iframe, rather than the top level + * window. + * + * @param string aString + * String to evaluate. + * @param object [aOptions] + * Options for evaluation: + * - bindObjectActor: the ObjectActor ID to use for evaluation. + * |evalWithBindings()| will be called with one additional binding: + * |_self| which will point to the Debugger.Object of the given + * ObjectActor. + * - selectedObjectActor: Like bindObjectActor, but executes with the + * top level window as the global. + * - frameActor: the FrameActor ID to use for evaluation. The given + * debugger frame is used for evaluation, instead of the global window. + * - selectedNodeActor: the NodeActor ID of the currently selected node + * in the Inspector (or null, if there is no selection). This is used + * for helper functions that make reference to the currently selected + * node, like $0. + * - url: the url to evaluate the script as. Defaults to + * "debugger eval code". + * @return object + * An object that holds the following properties: + * - dbg: the debugger where the string was evaluated. + * - frame: (optional) the frame where the string was evaluated. + * - window: the Debugger.Object for the global where the string was + * evaluated. + * - result: the result of the evaluation. + * - helperResult: any result coming from a Web Console commands + * function. + */ + evalWithDebugger: function WCA_evalWithDebugger(aString, aOptions = {}) + { + let trimmedString = aString.trim(); + // The help function needs to be easy to guess, so we make the () optional. + if (trimmedString == "help" || trimmedString == "?") { + aString = "help()"; + } + + // Add easter egg for console.mihai(). + if (trimmedString == "console.mihai()" || trimmedString == "console.mihai();") { + aString = "\"http://incompleteness.me/blog/2015/02/09/console-dot-mihai/\""; + } + + // Find the Debugger.Frame of the given FrameActor. + let frame = null, frameActor = null; + if (aOptions.frameActor) { + frameActor = this.conn.getActor(aOptions.frameActor); + if (frameActor) { + frame = frameActor.frame; + } + else { + DevToolsUtils.reportException("evalWithDebugger", + Error("The frame actor was not found: " + aOptions.frameActor)); + } + } + + // If we've been given a frame actor in whose scope we should evaluate the + // expression, be sure to use that frame's Debugger (that is, the JavaScript + // debugger's Debugger) for the whole operation, not the console's Debugger. + // (One Debugger will treat a different Debugger's Debugger.Object instances + // as ordinary objects, not as references to be followed, so mixing + // debuggers causes strange behaviors.) + let dbg = frame ? frameActor.threadActor.dbg : this.dbg; + let dbgWindow = dbg.makeGlobalObjectReference(this.evalWindow); + + // If we have an object to bind to |_self|, create a Debugger.Object + // referring to that object, belonging to dbg. + let bindSelf = null; + if (aOptions.bindObjectActor || aOptions.selectedObjectActor) { + let objActor = this.getActorByID(aOptions.bindObjectActor || + aOptions.selectedObjectActor); + if (objActor) { + let jsObj = objActor.obj.unsafeDereference(); + // If we use the makeDebuggeeValue method of jsObj's own global, then + // we'll get a D.O that sees jsObj as viewed from its own compartment - + // that is, without wrappers. The evalWithBindings call will then wrap + // jsObj appropriately for the evaluation compartment. + let global = Cu.getGlobalForObject(jsObj); + let _dbgWindow = dbg.makeGlobalObjectReference(global); + bindSelf = dbgWindow.makeDebuggeeValue(jsObj); + + if (aOptions.bindObjectActor) { + dbgWindow = _dbgWindow; + } + } + } + + // Get the Web Console commands for the given debugger window. + let helpers = this._getWebConsoleCommands(dbgWindow); + let bindings = helpers.sandbox; + if (bindSelf) { + bindings._self = bindSelf; + } + + if (aOptions.selectedNodeActor) { + let actor = this.conn.getActor(aOptions.selectedNodeActor); + if (actor) { + helpers.selectedNode = actor.rawNode; + } + } + + // Check if the Debugger.Frame or Debugger.Object for the global include + // $ or $$. We will not overwrite these functions with the Web Console + // commands. + let found$ = false, found$$ = false; + if (frame) { + let env = frame.environment; + if (env) { + found$ = !!env.find("$"); + found$$ = !!env.find("$$"); + } + } + else { + found$ = !!dbgWindow.getOwnPropertyDescriptor("$"); + found$$ = !!dbgWindow.getOwnPropertyDescriptor("$$"); + } + + let $ = null, $$ = null; + if (found$) { + $ = bindings.$; + delete bindings.$; + } + if (found$$) { + $$ = bindings.$$; + delete bindings.$$; + } + + // Ready to evaluate the string. + helpers.evalInput = aString; + + let evalOptions; + if (typeof aOptions.url == "string") { + evalOptions = { url: aOptions.url }; + } + + // If the debugger object is changed from the last evaluation, + // adopt this._lastConsoleInputEvaluation value in the new debugger, + // to prevents "Debugger.Object belongs to a different Debugger" exceptions + // related to the $_ bindings. + if (this._lastConsoleInputEvaluation && + this._lastConsoleInputEvaluation.global !== dbgWindow) { + this._lastConsoleInputEvaluation = dbg.adoptDebuggeeValue( + this._lastConsoleInputEvaluation + ); + } + + let result; + + if (frame) { + result = frame.evalWithBindings(aString, bindings, evalOptions); + } + else { + result = dbgWindow.executeInGlobalWithBindings(aString, bindings, evalOptions); + // Attempt to initialize any declarations found in the evaluated string + // since they may now be stuck in an "initializing" state due to the + // error. Already-initialized bindings will be ignored. + if ("throw" in result) { + let ast; + // Parse errors will raise an exception. We can/should ignore the error + // since it's already being handled elsewhere and we are only interested + // in initializing bindings. + try { + ast = Parser.reflectionAPI.parse(aString); + } catch (ex) { + ast = {"body": []}; + } + for (let line of ast.body) { + // Only let and const declarations put bindings into an + // "initializing" state. + if (!(line.kind == "let" || line.kind == "const")) + continue; + + let identifiers = []; + for (let decl of line.declarations) { + switch (decl.id.type) { + case "Identifier": + // let foo = bar; + identifiers.push(decl.id.name); + break; + case "ArrayPattern": + // let [foo, bar] = [1, 2]; + // let [foo=99, bar] = [1, 2]; + for (let e of decl.id.elements) { + if (e.type == "Identifier") { + identifiers.push(e.name); + } else if (e.type == "AssignmentExpression") { + identifiers.push(e.left.name); + } + } + break; + case "ObjectPattern": + // let {bilbo, my} = {bilbo: "baggins", my: "precious"}; + // let {blah: foo} = {blah: yabba()} + // let {blah: foo=99} = {blah: yabba()} + for (let prop of decl.id.properties) { + // key + if (prop.key.type == "Identifier") + identifiers.push(prop.key.name); + // value + if (prop.value.type == "Identifier") { + identifiers.push(prop.value.name); + } else if (prop.value.type == "AssignmentExpression") { + identifiers.push(prop.value.left.name); + } + } + break; + } + } + + for (let name of identifiers) + dbgWindow.forceLexicalInitializationByName(name); + } + } + } + + let helperResult = helpers.helperResult; + delete helpers.evalInput; + delete helpers.helperResult; + delete helpers.selectedNode; + + if ($) { + bindings.$ = $; + } + if ($$) { + bindings.$$ = $$; + } + + if (bindings._self) { + delete bindings._self; + } + + return { + result: result, + helperResult: helperResult, + dbg: dbg, + frame: frame, + window: dbgWindow, + }; + }, + + // Event handlers for various listeners. + + /** + * Handler for messages received from the ConsoleServiceListener. This method + * sends the nsIConsoleMessage to the remote Web Console client. + * + * @param nsIConsoleMessage aMessage + * The message we need to send to the client. + */ + onConsoleServiceMessage: function WCA_onConsoleServiceMessage(aMessage) + { + let packet; + if (aMessage instanceof Ci.nsIScriptError) { + packet = { + from: this.actorID, + type: "pageError", + pageError: this.preparePageErrorForRemote(aMessage), + }; + } + else { + packet = { + from: this.actorID, + type: "logMessage", + message: this._createStringGrip(aMessage.message), + timeStamp: aMessage.timeStamp, + }; + } + this.conn.send(packet); + }, + + /** + * Prepare an nsIScriptError to be sent to the client. + * + * @param nsIScriptError aPageError + * The page error we need to send to the client. + * @return object + * The object you can send to the remote client. + */ + preparePageErrorForRemote: function WCA_preparePageErrorForRemote(aPageError) + { + let stack = null; + // Convert stack objects to the JSON attributes expected by client code + if (aPageError.stack) { + stack = []; + let s = aPageError.stack; + while (s !== null) { + stack.push({ + filename: s.source, + lineNumber: s.line, + columnNumber: s.column, + functionName: s.functionDisplayName + }); + s = s.parent; + } + } + let lineText = aPageError.sourceLine; + if (lineText && lineText.length > DebuggerServer.LONG_STRING_INITIAL_LENGTH) { + lineText = lineText.substr(0, DebuggerServer.LONG_STRING_INITIAL_LENGTH); + } + + return { + errorMessage: this._createStringGrip(aPageError.errorMessage), + errorMessageName: aPageError.errorMessageName, + exceptionDocURL: ErrorDocs.GetURL(aPageError), + sourceName: aPageError.sourceName, + lineText: lineText, + lineNumber: aPageError.lineNumber, + columnNumber: aPageError.columnNumber, + category: aPageError.category, + timeStamp: aPageError.timeStamp, + warning: !!(aPageError.flags & aPageError.warningFlag), + error: !!(aPageError.flags & aPageError.errorFlag), + exception: !!(aPageError.flags & aPageError.exceptionFlag), + strict: !!(aPageError.flags & aPageError.strictFlag), + info: !!(aPageError.flags & aPageError.infoFlag), + private: aPageError.isFromPrivateWindow, + stacktrace: stack + }; + }, + + /** + * Handler for window.console API calls received from the ConsoleAPIListener. + * This method sends the object to the remote Web Console client. + * + * @see ConsoleAPIListener + * @param object aMessage + * The console API call we need to send to the remote client. + */ + onConsoleAPICall: function WCA_onConsoleAPICall(aMessage) + { + let packet = { + from: this.actorID, + type: "consoleAPICall", + message: this.prepareConsoleMessageForRemote(aMessage), + }; + this.conn.send(packet); + }, + + /** + * Handler for network events. This method is invoked when a new network event + * is about to be recorded. + * + * @see NetworkEventActor + * @see NetworkMonitor from webconsole/utils.js + * + * @param object aEvent + * The initial network request event information. + * @return object + * A new NetworkEventActor is returned. This is used for tracking the + * network request and response. + */ + onNetworkEvent: function WCA_onNetworkEvent(aEvent) + { + let actor = this.getNetworkEventActor(aEvent.channelId); + actor.init(aEvent); + + let packet = { + from: this.actorID, + type: "networkEvent", + eventActor: actor.grip() + }; + + this.conn.send(packet); + + return actor; + }, + + /** + * Get the NetworkEventActor for a nsIHttpChannel, if it exists, + * otherwise create a new one. + * + * @param string channelId + * The id of the channel for the network event. + * @return object + * The NetworkEventActor for the given channel. + */ + getNetworkEventActor: function WCA_getNetworkEventActor(channelId) { + let actor = this._netEvents.get(channelId); + if (actor) { + // delete from map as we should only need to do this check once + this._netEvents.delete(channelId); + return actor; + } + + actor = new NetworkEventActor(this); + this._actorPool.addActor(actor); + return actor; + }, + + /** + * Send a new HTTP request from the target's window. + * + * @param object message + * Object with 'request' - the HTTP request details. + */ + onSendHTTPRequest(message) { + let { url, method, headers, body } = message.request; + + // Set the loadingNode and loadGroup to the target document - otherwise the + // request won't show up in the opened netmonitor. + let doc = this.window.document; + + let channel = NetUtil.newChannel({ + uri: NetUtil.newURI(url), + loadingNode: doc, + securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_DATA_IS_NULL, + contentPolicyType: Ci.nsIContentPolicy.TYPE_OTHER + }); + + channel.QueryInterface(Ci.nsIHttpChannel); + + channel.loadGroup = doc.documentLoadGroup; + channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE | + Ci.nsIRequest.INHIBIT_CACHING | + Ci.nsIRequest.LOAD_ANONYMOUS; + + channel.requestMethod = method; + + for (let {name, value} of headers) { + channel.setRequestHeader(name, value, false); + } + + if (body) { + channel.QueryInterface(Ci.nsIUploadChannel2); + let bodyStream = Cc["@mozilla.org/io/string-input-stream;1"] + .createInstance(Ci.nsIStringInputStream); + bodyStream.setData(body, body.length); + channel.explicitSetUploadStream(bodyStream, null, -1, method, false); + } + + NetUtil.asyncFetch(channel, () => {}); + + let actor = this.getNetworkEventActor(channel.channelId); + + // map channel to actor so we can associate future events with it + this._netEvents.set(channel.channelId, actor); + + return { + from: this.actorID, + eventActor: actor.grip() + }; + }, + + /** + * Handler for file activity. This method sends the file request information + * to the remote Web Console client. + * + * @see ConsoleProgressListener + * @param string aFileURI + * The requested file URI. + */ + onFileActivity: function WCA_onFileActivity(aFileURI) + { + let packet = { + from: this.actorID, + type: "fileActivity", + uri: aFileURI, + }; + this.conn.send(packet); + }, + + /** + * Handler for reflow activity. This method forwards reflow events to the + * remote Web Console client. + * + * @see ConsoleReflowListener + * @param Object aReflowInfo + */ + onReflowActivity: function WCA_onReflowActivity(aReflowInfo) + { + let packet = { + from: this.actorID, + type: "reflowActivity", + interruptible: aReflowInfo.interruptible, + start: aReflowInfo.start, + end: aReflowInfo.end, + sourceURL: aReflowInfo.sourceURL, + sourceLine: aReflowInfo.sourceLine, + functionName: aReflowInfo.functionName + }; + + this.conn.send(packet); + }, + + /** + * Handler for server logging. This method forwards log events to the + * remote Web Console client. + * + * @see ServerLoggingListener + * @param object aMessage + * The console API call on the server we need to send to the remote client. + */ + onServerLogCall: function WCA_onServerLogCall(aMessage) + { + // Clone all data into the content scope (that's where + // passed arguments comes from). + let msg = Cu.cloneInto(aMessage, this.window); + + // All arguments within the message need to be converted into + // debuggees to properly send it to the client side. + // Use the default target: this.window as the global object + // since that's the correct scope for data in the message. + // The 'false' argument passed into prepareConsoleMessageForRemote() + // ensures that makeDebuggeeValue uses content debuggee. + // See also: + // * makeDebuggeeValue() + // * prepareConsoleMessageForRemote() + msg = this.prepareConsoleMessageForRemote(msg, false); + + let packet = { + from: this.actorID, + type: "serverLogCall", + message: msg, + }; + + this.conn.send(packet); + }, + + // End of event handlers for various listeners. + + /** + * Prepare a message from the console API to be sent to the remote Web Console + * instance. + * + * @param object aMessage + * The original message received from console-api-log-event. + * @param boolean aUseObjectGlobal + * If |true| the object global is determined and added as a debuggee, + * otherwise |this.window| is used when makeDebuggeeValue() is invoked. + * @return object + * The object that can be sent to the remote client. + */ + prepareConsoleMessageForRemote: + function WCA_prepareConsoleMessageForRemote(aMessage, aUseObjectGlobal = true) + { + let result = WebConsoleUtils.cloneObject(aMessage); + + result.workerType = WebConsoleUtils.getWorkerType(result) || "none"; + + delete result.wrappedJSObject; + delete result.ID; + delete result.innerID; + delete result.consoleID; + + result.arguments = Array.map(aMessage.arguments || [], (aObj) => { + let dbgObj = this.makeDebuggeeValue(aObj, aUseObjectGlobal); + return this.createValueGrip(dbgObj); + }); + + result.styles = Array.map(aMessage.styles || [], (aString) => { + return this.createValueGrip(aString); + }); + + result.category = aMessage.category || "webdev"; + + return result; + }, + + /** + * Find the XUL window that owns the content window. + * + * @return Window + * The XUL window that owns the content window. + */ + chromeWindow: function WCA_chromeWindow() + { + let window = null; + try { + window = this.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation).QueryInterface(Ci.nsIDocShell) + .chromeEventHandler.ownerDocument.defaultView; + } + catch (ex) { + // The above can fail because chromeEventHandler is not available for all + // kinds of |this.window|. + } + + return window; + }, + + /** + * Notification observer for the "last-pb-context-exited" topic. + * + * @private + * @param object aSubject + * Notification subject - in this case it is the inner window ID that + * was destroyed. + * @param string aTopic + * Notification topic. + */ + _onObserverNotification: function WCA__onObserverNotification(aSubject, aTopic) + { + switch (aTopic) { + case "last-pb-context-exited": + this.conn.send({ + from: this.actorID, + type: "lastPrivateContextExited", + }); + break; + } + }, + + /** + * The "will-navigate" progress listener. This is used to clear the current + * eval scope. + */ + _onWillNavigate: function WCA__onWillNavigate({ window, isTopLevel }) + { + if (isTopLevel) { + this._evalWindow = null; + events.off(this.parentActor, "will-navigate", this._onWillNavigate); + this._progressListenerActive = false; + } + }, + + /** + * This listener is called when we switch to another frame, + * mostly to unregister previous listeners and start listening on the new document. + */ + _onChangedToplevelDocument: function WCA__onChangedToplevelDocument() + { + // Convert the Set to an Array + let listeners = [...this._listeners]; + + // Unregister existing listener on the previous document + // (pass a copy of the array as it will shift from it) + this.onStopListeners({listeners: listeners.slice()}); + + // This method is called after this.window is changed, + // so we register new listener on this new window + this.onStartListeners({listeners: listeners}); + + // Also reset the cached top level chrome window being targeted + this._lastChromeWindow = null; + }, +}; + +WebConsoleActor.prototype.requestTypes = +{ + startListeners: WebConsoleActor.prototype.onStartListeners, + stopListeners: WebConsoleActor.prototype.onStopListeners, + getCachedMessages: WebConsoleActor.prototype.onGetCachedMessages, + evaluateJS: WebConsoleActor.prototype.onEvaluateJS, + evaluateJSAsync: WebConsoleActor.prototype.onEvaluateJSAsync, + autocomplete: WebConsoleActor.prototype.onAutocomplete, + clearMessagesCache: WebConsoleActor.prototype.onClearMessagesCache, + getPreferences: WebConsoleActor.prototype.onGetPreferences, + setPreferences: WebConsoleActor.prototype.onSetPreferences, + sendHTTPRequest: WebConsoleActor.prototype.onSendHTTPRequest +}; + +exports.WebConsoleActor = WebConsoleActor; + +/** + * Creates an actor for a network event. + * + * @constructor + * @param object webConsoleActor + * The parent WebConsoleActor instance for this object. + */ +function NetworkEventActor(webConsoleActor) { + this.parent = webConsoleActor; + this.conn = this.parent.conn; + + this._request = { + method: null, + url: null, + httpVersion: null, + headers: [], + cookies: [], + headersSize: null, + postData: {}, + }; + + this._response = { + headers: [], + cookies: [], + content: {}, + }; + + this._timings = {}; + + // Keep track of LongStringActors owned by this NetworkEventActor. + this._longStringActors = new Set(); +} + +NetworkEventActor.prototype = +{ + _request: null, + _response: null, + _timings: null, + _longStringActors: null, + + actorPrefix: "netEvent", + + /** + * Returns a grip for this actor for returning in a protocol message. + */ + grip: function NEA_grip() + { + return { + actor: this.actorID, + startedDateTime: this._startedDateTime, + timeStamp: Date.parse(this._startedDateTime), + url: this._request.url, + method: this._request.method, + isXHR: this._isXHR, + cause: this._cause, + fromCache: this._fromCache, + fromServiceWorker: this._fromServiceWorker, + private: this._private, + }; + }, + + /** + * Releases this actor from the pool. + */ + release: function NEA_release() + { + for (let grip of this._longStringActors) { + let actor = this.parent.getActorByID(grip.actor); + if (actor) { + this.parent.releaseActor(actor); + } + } + this._longStringActors = new Set(); + + if (this.channel) { + this.parent._netEvents.delete(this.channel); + } + this.parent.releaseActor(this); + }, + + /** + * Handle a protocol request to release a grip. + */ + onRelease: function NEA_onRelease() + { + this.release(); + return {}; + }, + + /** + * Set the properties of this actor based on it's corresponding + * network event. + * + * @param object aNetworkEvent + * The network event associated with this actor. + */ + init: function NEA_init(aNetworkEvent) + { + this._startedDateTime = aNetworkEvent.startedDateTime; + this._isXHR = aNetworkEvent.isXHR; + this._cause = aNetworkEvent.cause; + this._fromCache = aNetworkEvent.fromCache; + this._fromServiceWorker = aNetworkEvent.fromServiceWorker; + + for (let prop of ["method", "url", "httpVersion", "headersSize"]) { + this._request[prop] = aNetworkEvent[prop]; + } + + this._discardRequestBody = aNetworkEvent.discardRequestBody; + this._discardResponseBody = aNetworkEvent.discardResponseBody; + this._private = aNetworkEvent.private; + }, + + /** + * The "getRequestHeaders" packet type handler. + * + * @return object + * The response packet - network request headers. + */ + onGetRequestHeaders: function NEA_onGetRequestHeaders() + { + return { + from: this.actorID, + headers: this._request.headers, + headersSize: this._request.headersSize, + rawHeaders: this._request.rawHeaders, + }; + }, + + /** + * The "getRequestCookies" packet type handler. + * + * @return object + * The response packet - network request cookies. + */ + onGetRequestCookies: function NEA_onGetRequestCookies() + { + return { + from: this.actorID, + cookies: this._request.cookies, + }; + }, + + /** + * The "getRequestPostData" packet type handler. + * + * @return object + * The response packet - network POST data. + */ + onGetRequestPostData: function NEA_onGetRequestPostData() + { + return { + from: this.actorID, + postData: this._request.postData, + postDataDiscarded: this._discardRequestBody, + }; + }, + + /** + * The "getSecurityInfo" packet type handler. + * + * @return object + * The response packet - connection security information. + */ + onGetSecurityInfo: function NEA_onGetSecurityInfo() + { + return { + from: this.actorID, + securityInfo: this._securityInfo, + }; + }, + + /** + * The "getResponseHeaders" packet type handler. + * + * @return object + * The response packet - network response headers. + */ + onGetResponseHeaders: function NEA_onGetResponseHeaders() + { + return { + from: this.actorID, + headers: this._response.headers, + headersSize: this._response.headersSize, + rawHeaders: this._response.rawHeaders, + }; + }, + + /** + * The "getResponseCookies" packet type handler. + * + * @return object + * The response packet - network response cookies. + */ + onGetResponseCookies: function NEA_onGetResponseCookies() + { + return { + from: this.actorID, + cookies: this._response.cookies, + }; + }, + + /** + * The "getResponseContent" packet type handler. + * + * @return object + * The response packet - network response content. + */ + onGetResponseContent: function NEA_onGetResponseContent() + { + return { + from: this.actorID, + content: this._response.content, + contentDiscarded: this._discardResponseBody, + }; + }, + + /** + * The "getEventTimings" packet type handler. + * + * @return object + * The response packet - network event timings. + */ + onGetEventTimings: function NEA_onGetEventTimings() + { + return { + from: this.actorID, + timings: this._timings, + totalTime: this._totalTime + }; + }, + + /** **************************************************************** + * Listeners for new network event data coming from NetworkMonitor. + ******************************************************************/ + + /** + * Add network request headers. + * + * @param array aHeaders + * The request headers array. + * @param string aRawHeaders + * The raw headers source. + */ + addRequestHeaders: function NEA_addRequestHeaders(aHeaders, aRawHeaders) + { + this._request.headers = aHeaders; + this._prepareHeaders(aHeaders); + + var rawHeaders = this.parent._createStringGrip(aRawHeaders); + if (typeof rawHeaders == "object") { + this._longStringActors.add(rawHeaders); + } + this._request.rawHeaders = rawHeaders; + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "requestHeaders", + headers: aHeaders.length, + headersSize: this._request.headersSize, + }; + + this.conn.send(packet); + }, + + /** + * Add network request cookies. + * + * @param array aCookies + * The request cookies array. + */ + addRequestCookies: function NEA_addRequestCookies(aCookies) + { + this._request.cookies = aCookies; + this._prepareHeaders(aCookies); + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "requestCookies", + cookies: aCookies.length, + }; + + this.conn.send(packet); + }, + + /** + * Add network request POST data. + * + * @param object aPostData + * The request POST data. + */ + addRequestPostData: function NEA_addRequestPostData(aPostData) + { + this._request.postData = aPostData; + aPostData.text = this.parent._createStringGrip(aPostData.text); + if (typeof aPostData.text == "object") { + this._longStringActors.add(aPostData.text); + } + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "requestPostData", + dataSize: aPostData.text.length, + discardRequestBody: this._discardRequestBody, + }; + + this.conn.send(packet); + }, + + /** + * Add the initial network response information. + * + * @param object aInfo + * The response information. + * @param string aRawHeaders + * The raw headers source. + */ + addResponseStart: function NEA_addResponseStart(aInfo, aRawHeaders) + { + var rawHeaders = this.parent._createStringGrip(aRawHeaders); + if (typeof rawHeaders == "object") { + this._longStringActors.add(rawHeaders); + } + this._response.rawHeaders = rawHeaders; + + this._response.httpVersion = aInfo.httpVersion; + this._response.status = aInfo.status; + this._response.statusText = aInfo.statusText; + this._response.headersSize = aInfo.headersSize; + this._discardResponseBody = aInfo.discardResponseBody; + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "responseStart", + response: aInfo + }; + + this.conn.send(packet); + }, + + /** + * Add connection security information. + * + * @param object info + * The object containing security information. + */ + addSecurityInfo: function NEA_addSecurityInfo(info) + { + this._securityInfo = info; + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "securityInfo", + state: info.state, + }; + + this.conn.send(packet); + }, + + /** + * Add network response headers. + * + * @param array aHeaders + * The response headers array. + */ + addResponseHeaders: function NEA_addResponseHeaders(aHeaders) + { + this._response.headers = aHeaders; + this._prepareHeaders(aHeaders); + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "responseHeaders", + headers: aHeaders.length, + headersSize: this._response.headersSize, + }; + + this.conn.send(packet); + }, + + /** + * Add network response cookies. + * + * @param array aCookies + * The response cookies array. + */ + addResponseCookies: function NEA_addResponseCookies(aCookies) + { + this._response.cookies = aCookies; + this._prepareHeaders(aCookies); + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "responseCookies", + cookies: aCookies.length, + }; + + this.conn.send(packet); + }, + + /** + * Add network response content. + * + * @param object aContent + * The response content. + * @param boolean aDiscardedResponseBody + * Tells if the response content was recorded or not. + */ + addResponseContent: + function NEA_addResponseContent(aContent, aDiscardedResponseBody) + { + this._response.content = aContent; + aContent.text = this.parent._createStringGrip(aContent.text); + if (typeof aContent.text == "object") { + this._longStringActors.add(aContent.text); + } + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "responseContent", + mimeType: aContent.mimeType, + contentSize: aContent.size, + encoding: aContent.encoding, + transferredSize: aContent.transferredSize, + discardResponseBody: aDiscardedResponseBody, + }; + + this.conn.send(packet); + }, + + /** + * Add network event timing information. + * + * @param number aTotal + * The total time of the network event. + * @param object aTimings + * Timing details about the network event. + */ + addEventTimings: function NEA_addEventTimings(aTotal, aTimings) + { + this._totalTime = aTotal; + this._timings = aTimings; + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "eventTimings", + totalTime: aTotal + }; + + this.conn.send(packet); + }, + + /** + * Prepare the headers array to be sent to the client by using the + * LongStringActor for the header values, when needed. + * + * @private + * @param array aHeaders + */ + _prepareHeaders: function NEA__prepareHeaders(aHeaders) + { + for (let header of aHeaders) { + header.value = this.parent._createStringGrip(header.value); + if (typeof header.value == "object") { + this._longStringActors.add(header.value); + } + } + }, +}; + +NetworkEventActor.prototype.requestTypes = +{ + "release": NetworkEventActor.prototype.onRelease, + "getRequestHeaders": NetworkEventActor.prototype.onGetRequestHeaders, + "getRequestCookies": NetworkEventActor.prototype.onGetRequestCookies, + "getRequestPostData": NetworkEventActor.prototype.onGetRequestPostData, + "getResponseHeaders": NetworkEventActor.prototype.onGetResponseHeaders, + "getResponseCookies": NetworkEventActor.prototype.onGetResponseCookies, + "getResponseContent": NetworkEventActor.prototype.onGetResponseContent, + "getEventTimings": NetworkEventActor.prototype.onGetEventTimings, + "getSecurityInfo": NetworkEventActor.prototype.onGetSecurityInfo, +}; diff --git a/devtools/server/actors/webextension.js b/devtools/server/actors/webextension.js new file mode 100644 index 000000000..0e83fc999 --- /dev/null +++ b/devtools/server/actors/webextension.js @@ -0,0 +1,333 @@ +/* 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, Cu } = require("chrome"); +const Services = require("Services"); +const { ChromeActor } = require("./chrome"); +const makeDebugger = require("./utils/make-debugger"); + +var DevToolsUtils = require("devtools/shared/DevToolsUtils"); +var { assert } = DevToolsUtils; + +loader.lazyRequireGetter(this, "mapURIToAddonID", "devtools/server/actors/utils/map-uri-to-addon-id"); +loader.lazyRequireGetter(this, "unwrapDebuggerObjectGlobal", "devtools/server/actors/script", true); + +loader.lazyImporter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm"); +loader.lazyImporter(this, "XPIProvider", "resource://gre/modules/addons/XPIProvider.jsm"); + +const FALLBACK_DOC_MESSAGE = "Your addon does not have any document opened yet."; + +/** + * Creates a TabActor for debugging all the contexts associated to a target WebExtensions + * add-on. + * Most of the implementation is inherited from ChromeActor (which inherits most of its + * implementation from TabActor). + * WebExtensionActor is a child of RootActor, it can be retrieved via + * RootActor.listAddons request. + * WebExtensionActor exposes all tab actors via its form() request, like TabActor. + * + * History lecture: + * The add-on actors used to not inherit TabActor because of the different way the + * add-on APIs where exposed to the add-on itself, and for this reason the Addon Debugger + * has only a sub-set of the feature available in the Tab or in the Browser Toolbox. + * In a WebExtensions add-on all the provided contexts (background and popup pages etc.), + * besides the Content Scripts which run in the content process, hooked to an existent + * tab, by creating a new WebExtensionActor which inherits from ChromeActor, we can + * provide a full features Addon Toolbox (which is basically like a BrowserToolbox which + * filters the visible sources and frames to the one that are related to the target + * add-on). + * + * @param conn DebuggerServerConnection + * The connection to the client. + * @param addon AddonWrapper + * The target addon. + */ +function WebExtensionActor(conn, addon) { + ChromeActor.call(this, conn); + + this.id = addon.id; + this.addon = addon; + + // Bind the _allowSource helper to this, it is used in the + // TabActor to lazily create the TabSources instance. + this._allowSource = this._allowSource.bind(this); + + // Set the consoleAPIListener filtering options + // (retrieved and used in the related webconsole child actor). + this.consoleAPIListenerOptions = { + addonId: addon.id, + }; + + // This creates a Debugger instance for debugging all the add-on globals. + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: dbg => { + return dbg.findAllGlobals().filter(this._shouldAddNewGlobalAsDebuggee); + }, + shouldAddNewGlobalAsDebuggee: this._shouldAddNewGlobalAsDebuggee.bind(this), + }); + + // Discover the preferred debug global for the target addon + this.preferredTargetWindow = null; + this._findAddonPreferredTargetWindow(); + + AddonManager.addAddonListener(this); +} +exports.WebExtensionActor = WebExtensionActor; + +WebExtensionActor.prototype = Object.create(ChromeActor.prototype); + +WebExtensionActor.prototype.actorPrefix = "webExtension"; +WebExtensionActor.prototype.constructor = WebExtensionActor; + +// NOTE: This is needed to catch in the webextension webconsole all the +// errors raised by the WebExtension internals that are not currently +// associated with any window. +WebExtensionActor.prototype.isRootActor = true; + +WebExtensionActor.prototype.form = function () { + assert(this.actorID, "addon should have an actorID."); + + let baseForm = ChromeActor.prototype.form.call(this); + + return Object.assign(baseForm, { + actor: this.actorID, + id: this.id, + name: this.addon.name, + url: this.addon.sourceURI ? this.addon.sourceURI.spec : undefined, + iconURL: this.addon.iconURL, + debuggable: this.addon.isDebuggable, + temporarilyInstalled: this.addon.temporarilyInstalled, + isWebExtension: this.addon.isWebExtension, + }); +}; + +WebExtensionActor.prototype._attach = function () { + // NOTE: we need to be sure that `this.window` can return a + // window before calling the ChromeActor.onAttach, or the TabActor + // will not be subscribed to the child doc shell updates. + + // If a preferredTargetWindow exists, set it as the target for this actor + // when the client request to attach this actor. + if (this.preferredTargetWindow) { + this._setWindow(this.preferredTargetWindow); + } else { + this._createFallbackWindow(); + } + + // Call ChromeActor's _attach to listen for any new/destroyed chrome docshell + ChromeActor.prototype._attach.apply(this); +}; + +WebExtensionActor.prototype._detach = function () { + this._destroyFallbackWindow(); + + // Call ChromeActor's _detach to unsubscribe new/destroyed chrome docshell listeners. + ChromeActor.prototype._detach.apply(this); +}; + +/** + * Called when the actor is removed from the connection. + */ +WebExtensionActor.prototype.exit = function () { + AddonManager.removeAddonListener(this); + + this.preferredTargetWindow = null; + this.addon = null; + this.id = null; + + return ChromeActor.prototype.exit.apply(this); +}; + +// Addon Specific Remote Debugging requestTypes and methods. + +/** + * Reloads the addon. + */ +WebExtensionActor.prototype.onReload = function () { + return this.addon.reload() + .then(() => { + // send an empty response + return {}; + }); +}; + +/** + * Set the preferred global for the add-on (called from the AddonManager). + */ +WebExtensionActor.prototype.setOptions = function (addonOptions) { + if ("global" in addonOptions) { + // Set the proposed debug global as the preferred target window + // (the actor will eventually set it as the target once it is attached) + this.preferredTargetWindow = addonOptions.global; + } +}; + +// AddonManagerListener callbacks. + +WebExtensionActor.prototype.onInstalled = function (addon) { + if (addon.id != this.id) { + return; + } + + // Update the AddonManager's addon object on reload/update. + this.addon = addon; +}; + +WebExtensionActor.prototype.onUninstalled = function (addon) { + if (addon != this.addon) { + return; + } + + this.exit(); +}; + +WebExtensionActor.prototype.onPropertyChanged = function (addon, changedPropNames) { + if (addon != this.addon) { + return; + } + + // Refresh the preferred debug global on disabled/reloaded/upgraded addon. + if (changedPropNames.includes("debugGlobal")) { + this._findAddonPreferredTargetWindow(); + } +}; + +// Private helpers + +WebExtensionActor.prototype._createFallbackWindow = function () { + if (this.fallbackWindow) { + // Skip if there is already an existent fallback window. + return; + } + + // Create an empty hidden window as a fallback (e.g. the background page could be + // not defined for the target add-on or not yet when the actor instance has been + // created). + this.fallbackWebNav = Services.appShell.createWindowlessBrowser(true); + this.fallbackWebNav.loadURI( + `data:text/html;charset=utf-8,${FALLBACK_DOC_MESSAGE}`, + 0, null, null, null + ); + + this.fallbackDocShell = this.fallbackWebNav + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell); + + Object.defineProperty(this, "docShell", { + value: this.fallbackDocShell, + configurable: true + }); + + // Save the reference to the fallback DOMWindow + this.fallbackWindow = this.fallbackDocShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); +}; + +WebExtensionActor.prototype._destroyFallbackWindow = function () { + if (this.fallbackWebNav) { + // Explicitly close the fallback windowless browser to prevent it to leak + // (and to prevent it to freeze devtools xpcshell tests). + this.fallbackWebNav.loadURI("about:blank", 0, null, null, null); + this.fallbackWebNav.close(); + + this.fallbackWebNav = null; + this.fallbackWindow = null; + } +}; + +/** + * Discover the preferred debug global and switch to it if the addon has been attached. + */ +WebExtensionActor.prototype._findAddonPreferredTargetWindow = function () { + return new Promise(resolve => { + let activeAddon = XPIProvider.activeAddons.get(this.id); + + if (!activeAddon) { + // The addon is not active, the background page is going to be destroyed, + // navigate to the fallback window (if it already exists). + resolve(null); + } else { + AddonManager.getAddonByInstanceID(activeAddon.instanceID) + .then(privateWrapper => { + let targetWindow = privateWrapper.getDebugGlobal(); + + // Do not use the preferred global if it is not a DOMWindow as expected. + if (!(targetWindow instanceof Ci.nsIDOMWindow)) { + targetWindow = null; + } + + resolve(targetWindow); + }); + } + }).then(preferredTargetWindow => { + this.preferredTargetWindow = preferredTargetWindow; + + if (!preferredTargetWindow) { + // Create a fallback window if no preferred target window has been found. + this._createFallbackWindow(); + } else if (this.attached) { + // Change the top level document if the actor is already attached. + this._changeTopLevelDocument(preferredTargetWindow); + } + }); +}; + +/** + * Return an array of the json details related to an array/iterator of docShells. + */ +WebExtensionActor.prototype._docShellsToWindows = function (docshells) { + return ChromeActor.prototype._docShellsToWindows.call(this, docshells) + .filter(windowDetails => { + // filter the docShells based on the addon id + return windowDetails.addonID == this.id; + }); +}; + +/** + * Return true if the given source is associated with this addon and should be + * added to the visible sources (retrieved and used by the webbrowser actor module). + */ +WebExtensionActor.prototype._allowSource = function (source) { + try { + let uri = Services.io.newURI(source.url, null, null); + let addonID = mapURIToAddonID(uri); + + return addonID == this.id; + } catch (e) { + return false; + } +}; + +/** + * Return true if the given global is associated with this addon and should be + * added as a debuggee, false otherwise. + */ +WebExtensionActor.prototype._shouldAddNewGlobalAsDebuggee = function (newGlobal) { + const global = unwrapDebuggerObjectGlobal(newGlobal); + + if (global instanceof Ci.nsIDOMWindow) { + return global.document.nodePrincipal.originAttributes.addonId == this.id; + } + + try { + // This will fail for non-Sandbox objects, hence the try-catch block. + let metadata = Cu.getSandboxMetadata(global); + if (metadata) { + return metadata.addonID === this.id; + } + } catch (e) { + // Unable to retrieve the sandbox metadata. + } + + return false; +}; + +/** + * Override WebExtensionActor requestTypes: + * - redefined `reload`, which should reload the target addon + * (instead of the entire browser as the regular ChromeActor does). + */ +WebExtensionActor.prototype.requestTypes.reload = WebExtensionActor.prototype.onReload; diff --git a/devtools/server/actors/webgl.js b/devtools/server/actors/webgl.js new file mode 100644 index 000000000..137448647 --- /dev/null +++ b/devtools/server/actors/webgl.js @@ -0,0 +1,1322 @@ +/* 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 {Cc, Ci, Cu, Cr} = require("chrome"); +const events = require("sdk/event/core"); +const promise = require("promise"); +const protocol = require("devtools/shared/protocol"); +const { ContentObserver } = require("devtools/shared/content-observer"); +const { on, once, off, emit } = events; +const { method, Arg, Option, RetVal } = protocol; +const { + shaderSpec, + programSpec, + webGLSpec, +} = require("devtools/shared/specs/webgl"); + +const WEBGL_CONTEXT_NAMES = ["webgl", "experimental-webgl", "moz-webgl"]; + +// These traits are bit masks. Make sure they're powers of 2. +const PROGRAM_DEFAULT_TRAITS = 0; +const PROGRAM_BLACKBOX_TRAIT = 1; +const PROGRAM_HIGHLIGHT_TRAIT = 2; + +/** + * A WebGL Shader contributing to building a WebGL Program. + * You can either retrieve, or compile the source of a shader, which will + * automatically inflict the necessary changes to the WebGL state. + */ +var ShaderActor = protocol.ActorClassWithSpec(shaderSpec, { + /** + * Create the shader actor. + * + * @param DebuggerServerConnection conn + * The server connection. + * @param WebGLProgram program + * The WebGL program being linked. + * @param WebGLShader shader + * The cooresponding vertex or fragment shader. + * @param WebGLProxy proxy + * The proxy methods for the WebGL context owning this shader. + */ + initialize: function (conn, program, shader, proxy) { + protocol.Actor.prototype.initialize.call(this, conn); + this.program = program; + this.shader = shader; + this.text = proxy.getShaderSource(shader); + this.linkedProxy = proxy; + }, + + /** + * Gets the source code for this shader. + */ + getText: function () { + return this.text; + }, + + /** + * Sets and compiles new source code for this shader. + */ + compile: function (text) { + // Get the shader and corresponding program to change via the WebGL proxy. + let { linkedProxy: proxy, shader, program } = this; + + // Get the new shader source to inject. + let oldText = this.text; + let newText = text; + + // Overwrite the shader's source. + let error = proxy.compileShader(program, shader, this.text = newText); + + // If something went wrong, revert to the previous shader. + if (error.compile || error.link) { + proxy.compileShader(program, shader, this.text = oldText); + return error; + } + return undefined; + } +}); + +/** + * A WebGL program is composed (at the moment, analogue to OpenGL ES 2.0) + * of two shaders: a vertex shader and a fragment shader. + */ +var ProgramActor = protocol.ActorClassWithSpec(programSpec, { + /** + * Create the program actor. + * + * @param DebuggerServerConnection conn + * The server connection. + * @param WebGLProgram program + * The WebGL program being linked. + * @param WebGLShader[] shaders + * The WebGL program's cooresponding vertex and fragment shaders. + * @param WebGLCache cache + * The state storage for the WebGL context owning this program. + * @param WebGLProxy proxy + * The proxy methods for the WebGL context owning this program. + */ + initialize: function (conn, [program, shaders, cache, proxy]) { + protocol.Actor.prototype.initialize.call(this, conn); + this._shaderActorsCache = { vertex: null, fragment: null }; + this.program = program; + this.shaders = shaders; + this.linkedCache = cache; + this.linkedProxy = proxy; + }, + + get ownerWindow() { + return this.linkedCache.ownerWindow; + }, + + get ownerContext() { + return this.linkedCache.ownerContext; + }, + + /** + * Gets the vertex shader linked to this program. This method guarantees + * a single actor instance per shader. + */ + getVertexShader: function () { + return this._getShaderActor("vertex"); + }, + + /** + * Gets the fragment shader linked to this program. This method guarantees + * a single actor instance per shader. + */ + getFragmentShader: function () { + return this._getShaderActor("fragment"); + }, + + /** + * Highlights any geometry rendered using this program. + */ + highlight: function (tint) { + this.linkedProxy.highlightTint = tint; + this.linkedCache.setProgramTrait(this.program, PROGRAM_HIGHLIGHT_TRAIT); + }, + + /** + * Allows geometry to be rendered normally using this program. + */ + unhighlight: function () { + this.linkedCache.unsetProgramTrait(this.program, PROGRAM_HIGHLIGHT_TRAIT); + }, + + /** + * Prevents any geometry from being rendered using this program. + */ + blackbox: function () { + this.linkedCache.setProgramTrait(this.program, PROGRAM_BLACKBOX_TRAIT); + }, + + /** + * Allows geometry to be rendered using this program. + */ + unblackbox: function () { + this.linkedCache.unsetProgramTrait(this.program, PROGRAM_BLACKBOX_TRAIT); + }, + + /** + * Returns a cached ShaderActor instance based on the required shader type. + * + * @param string type + * Either "vertex" or "fragment". + * @return ShaderActor + * The respective shader actor instance. + */ + _getShaderActor: function (type) { + if (this._shaderActorsCache[type]) { + return this._shaderActorsCache[type]; + } + let proxy = this.linkedProxy; + let shader = proxy.getShaderOfType(this.shaders, type); + let shaderActor = new ShaderActor(this.conn, this.program, shader, proxy); + return this._shaderActorsCache[type] = shaderActor; + } +}); + +/** + * The WebGL Actor handles simple interaction with a WebGL context via a few + * high-level methods. After instantiating this actor, you'll need to set it + * up by calling setup(). + */ +var WebGLActor = exports.WebGLActor = protocol.ActorClassWithSpec(webGLSpec, { + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this._onGlobalCreated = this._onGlobalCreated.bind(this); + this._onGlobalDestroyed = this._onGlobalDestroyed.bind(this); + this._onProgramLinked = this._onProgramLinked.bind(this); + }, + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + this.finalize(); + }, + + /** + * Starts waiting for the current tab actor's document global to be + * created, in order to instrument the Canvas context and become + * aware of everything the content does WebGL-wise. + * + * See ContentObserver and WebGLInstrumenter for more details. + */ + setup: function ({ reload }) { + if (this._initialized) { + return; + } + this._initialized = true; + + this._programActorsCache = []; + this._webglObserver = new WebGLObserver(); + + on(this.tabActor, "window-ready", this._onGlobalCreated); + on(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + on(this._webglObserver, "program-linked", this._onProgramLinked); + + if (reload) { + this.tabActor.window.location.reload(); + } + }, + + /** + * Stops listening for document global changes and puts this actor + * to hibernation. This method is called automatically just before the + * actor is destroyed. + */ + finalize: function () { + if (!this._initialized) { + return; + } + this._initialized = false; + + off(this.tabActor, "window-ready", this._onGlobalCreated); + off(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + off(this._webglObserver, "program-linked", this._onProgramLinked); + + this._programActorsCache = null; + this._contentObserver = null; + this._webglObserver = null; + }, + + /** + * Gets an array of cached program actors for the current tab actor's window. + * This is useful for dealing with bfcache, when no new programs are linked. + */ + getPrograms: function () { + let id = ContentObserver.GetInnerWindowID(this.tabActor.window); + return this._programActorsCache.filter(e => e.ownerWindow == id); + }, + + /** + * Waits for one frame via `requestAnimationFrame` on the tab actor's window. + * Used in tests. + */ + waitForFrame: function () { + let deferred = promise.defer(); + this.tabActor.window.requestAnimationFrame(deferred.resolve); + return deferred.promise; + }, + + /** + * Gets a pixel's RGBA value from a context specified by selector + * and the coordinates of the pixel in question. + * Currently only used in tests. + * + * @param string selector + * A string selector to select the canvas in question from the DOM. + * @param Object position + * An object with an `x` and `y` property indicating coordinates of the pixel being inspected. + * @return Object + * An object containing `r`, `g`, `b`, and `a` properties of the pixel. + */ + getPixel: function ({ selector, position }) { + let { x, y } = position; + let canvas = this.tabActor.window.document.querySelector(selector); + let context = XPCNativeWrapper.unwrap(canvas.getContext("webgl")); + let { proxy } = this._webglObserver.for(context); + let height = canvas.height; + + let buffer = new this.tabActor.window.Uint8Array(4); + buffer = XPCNativeWrapper.unwrap(buffer); + + proxy.readPixels(x, height - y - 1, 1, 1, context.RGBA, context.UNSIGNED_BYTE, buffer); + + return { r: buffer[0], g: buffer[1], b: buffer[2], a: buffer[3] }; + }, + + /** + * Gets an array of all cached program actors belonging to all windows. + * This should only be used for tests. + */ + _getAllPrograms: function () { + return this._programActorsCache; + }, + + + /** + * Invoked whenever the current tab actor's document global is created. + */ + _onGlobalCreated: function ({id, window, isTopLevel}) { + if (isTopLevel) { + WebGLInstrumenter.handle(window, this._webglObserver); + events.emit(this, "global-created", id); + } + }, + + /** + * Invoked whenever the current tab actor's inner window is destroyed. + */ + _onGlobalDestroyed: function ({id, isTopLevel, isFrozen}) { + if (isTopLevel && !isFrozen) { + removeFromArray(this._programActorsCache, e => e.ownerWindow == id); + this._webglObserver.unregisterContextsForWindow(id); + events.emit(this, "global-destroyed", id); + } + }, + + /** + * Invoked whenever an observed WebGL context links a program. + */ + _onProgramLinked: function (...args) { + let programActor = new ProgramActor(this.conn, args); + this._programActorsCache.push(programActor); + events.emit(this, "program-linked", programActor); + } +}); + +/** + * Instruments a HTMLCanvasElement with the appropriate inspection methods. + */ +var WebGLInstrumenter = { + /** + * Overrides the getContext method in the HTMLCanvasElement prototype. + * + * @param nsIDOMWindow window + * The window to perform the instrumentation in. + * @param WebGLObserver observer + * The observer watching function calls in the context. + */ + handle: function (window, observer) { + let self = this; + + let id = ContentObserver.GetInnerWindowID(window); + let canvasElem = XPCNativeWrapper.unwrap(window.HTMLCanvasElement); + let canvasPrototype = canvasElem.prototype; + let originalGetContext = canvasPrototype.getContext; + + /** + * Returns a drawing context on the canvas, or null if the context ID is + * not supported. This override creates an observer for the targeted context + * type and instruments specific functions in the targeted context instance. + */ + canvasPrototype.getContext = function (name, options) { + // Make sure a context was able to be created. + let context = originalGetContext.call(this, name, options); + if (!context) { + return context; + } + // Make sure a WebGL (not a 2D) context will be instrumented. + if (WEBGL_CONTEXT_NAMES.indexOf(name) == -1) { + return context; + } + // Repeated calls to 'getContext' return the same instance, no need to + // instrument everything again. + if (observer.for(context)) { + return context; + } + + // Create a separate state storage for this context. + observer.registerContextForWindow(id, context); + + // Link our observer to the new WebGL context methods. + for (let { timing, callback, functions } of self._methods) { + for (let func of functions) { + self._instrument(observer, context, func, callback, timing); + } + } + + // Return the decorated context back to the content consumer, which + // will continue using it normally. + return context; + }; + }, + + /** + * Overrides a specific method in a HTMLCanvasElement context. + * + * @param WebGLObserver observer + * The observer watching function calls in the context. + * @param WebGLRenderingContext context + * The targeted WebGL context instance. + * @param string funcName + * The function to override. + * @param array callbackName [optional] + * The two callback function names in the observer, corresponding to + * the "before" and "after" invocation times. If unspecified, they will + * default to the name of the function to override. + * @param number timing [optional] + * When to issue the callback in relation to the actual context + * function call. Availalble values are -1 for "before" (default) + * 1 for "after" and 0 for "before and after". + */ + _instrument: function (observer, context, funcName, callbackName = [], timing = -1) { + let { cache, proxy } = observer.for(context); + let originalFunc = context[funcName]; + let beforeFuncName = callbackName[0] || funcName; + let afterFuncName = callbackName[1] || callbackName[0] || funcName; + + context[funcName] = function (...glArgs) { + if (timing <= 0 && !observer.suppressHandlers) { + let glBreak = observer[beforeFuncName](glArgs, cache, proxy); + if (glBreak) return undefined; + } + + // Invoking .apply on an unxrayed content function doesn't work, because + // the arguments array is inaccessible to it. Get Xrays back. + let glResult = Cu.waiveXrays(Cu.unwaiveXrays(originalFunc).apply(this, glArgs)); + + if (timing >= 0 && !observer.suppressHandlers) { + let glBreak = observer[afterFuncName](glArgs, glResult, cache, proxy); + if (glBreak) return undefined; + } + + return glResult; + }; + }, + + /** + * Override mappings for WebGL methods. + */ + _methods: [{ + timing: 1, // after + functions: [ + "linkProgram", "getAttribLocation", "getUniformLocation" + ] + }, { + timing: -1, // before + callback: [ + "toggleVertexAttribArray" + ], + functions: [ + "enableVertexAttribArray", "disableVertexAttribArray" + ] + }, { + timing: -1, // before + callback: [ + "attribute_" + ], + functions: [ + "vertexAttrib1f", "vertexAttrib2f", "vertexAttrib3f", "vertexAttrib4f", + "vertexAttrib1fv", "vertexAttrib2fv", "vertexAttrib3fv", "vertexAttrib4fv", + "vertexAttribPointer" + ] + }, { + timing: -1, // before + callback: [ + "uniform_" + ], + functions: [ + "uniform1i", "uniform2i", "uniform3i", "uniform4i", + "uniform1f", "uniform2f", "uniform3f", "uniform4f", + "uniform1iv", "uniform2iv", "uniform3iv", "uniform4iv", + "uniform1fv", "uniform2fv", "uniform3fv", "uniform4fv", + "uniformMatrix2fv", "uniformMatrix3fv", "uniformMatrix4fv" + ] + }, { + timing: -1, // before + functions: [ + "useProgram", "enable", "disable", "blendColor", + "blendEquation", "blendEquationSeparate", + "blendFunc", "blendFuncSeparate" + ] + }, { + timing: 0, // before and after + callback: [ + "beforeDraw_", "afterDraw_" + ], + functions: [ + "drawArrays", "drawElements" + ] + }] + // TODO: It'd be a good idea to handle other functions as well: + // - getActiveUniform + // - getUniform + // - getActiveAttrib + // - getVertexAttrib +}; + +/** + * An observer that captures a WebGL context's method calls. + */ +function WebGLObserver() { + this._contexts = new Map(); +} + +WebGLObserver.prototype = { + _contexts: null, + + /** + * Creates a WebGLCache and a WebGLProxy for the specified window and context. + * + * @param number id + * The id of the window containing the WebGL context. + * @param WebGLRenderingContext context + * The WebGL context used in the cache and proxy instances. + */ + registerContextForWindow: function (id, context) { + let cache = new WebGLCache(id, context); + let proxy = new WebGLProxy(id, context, cache, this); + cache.refreshState(proxy); + + this._contexts.set(context, { + ownerWindow: id, + cache: cache, + proxy: proxy + }); + }, + + /** + * Removes all WebGLCache and WebGLProxy instances for a particular window. + * + * @param number id + * The id of the window containing the WebGL context. + */ + unregisterContextsForWindow: function (id) { + removeFromMap(this._contexts, e => e.ownerWindow == id); + }, + + /** + * Gets the WebGLCache and WebGLProxy instances for a particular context. + * + * @param WebGLRenderingContext context + * The WebGL context used in the cache and proxy instances. + * @return object + * An object containing the corresponding { cache, proxy } instances. + */ + for: function (context) { + return this._contexts.get(context); + }, + + /** + * Set this flag to true to stop observing any context function calls. + */ + suppressHandlers: false, + + /** + * Called immediately *after* 'linkProgram' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param void glResult + * The returned value of the original function call. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + * @param WebGLProxy proxy + * The proxy methods for the WebGL context initiating this call. + */ + linkProgram: function (glArgs, glResult, cache, proxy) { + let program = glArgs[0]; + let shaders = proxy.getAttachedShaders(program); + cache.addProgram(program, PROGRAM_DEFAULT_TRAITS); + emit(this, "program-linked", program, shaders, cache, proxy); + }, + + /** + * Called immediately *after* 'getAttribLocation' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param GLint glResult + * The returned value of the original function call. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + getAttribLocation: function (glArgs, glResult, cache) { + // Make sure the attribute's value is legal before caching. + if (glResult < 0) { + return; + } + let [program, name] = glArgs; + cache.addAttribute(program, name, glResult); + }, + + /** + * Called immediately *after* 'getUniformLocation' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLUniformLocation glResult + * The returned value of the original function call. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + getUniformLocation: function (glArgs, glResult, cache) { + // Make sure the uniform's value is legal before caching. + if (!glResult) { + return; + } + let [program, name] = glArgs; + cache.addUniform(program, name, glResult); + }, + + /** + * Called immediately *before* 'enableVertexAttribArray' or + * 'disableVertexAttribArray'is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + toggleVertexAttribArray: function (glArgs, cache) { + glArgs[0] = cache.getCurrentAttributeLocation(glArgs[0]); + return glArgs[0] < 0; // Return true to break original function call. + }, + + /** + * Called immediately *before* 'attribute_' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + attribute_: function (glArgs, cache) { + glArgs[0] = cache.getCurrentAttributeLocation(glArgs[0]); + return glArgs[0] < 0; // Return true to break original function call. + }, + + /** + * Called immediately *before* 'uniform_' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + uniform_: function (glArgs, cache) { + glArgs[0] = cache.getCurrentUniformLocation(glArgs[0]); + return !glArgs[0]; // Return true to break original function call. + }, + + /** + * Called immediately *before* 'useProgram' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + useProgram: function (glArgs, cache) { + // Manually keeping a cache and not using gl.getParameter(CURRENT_PROGRAM) + // because gl.get* functions are slow as potatoes. + cache.currentProgram = glArgs[0]; + }, + + /** + * Called immediately *before* 'enable' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + enable: function (glArgs, cache) { + cache.currentState[glArgs[0]] = true; + }, + + /** + * Called immediately *before* 'disable' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + disable: function (glArgs, cache) { + cache.currentState[glArgs[0]] = false; + }, + + /** + * Called immediately *before* 'blendColor' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + blendColor: function (glArgs, cache) { + let blendColor = cache.currentState.blendColor; + blendColor[0] = glArgs[0]; + blendColor[1] = glArgs[1]; + blendColor[2] = glArgs[2]; + blendColor[3] = glArgs[3]; + }, + + /** + * Called immediately *before* 'blendEquation' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + blendEquation: function (glArgs, cache) { + let state = cache.currentState; + state.blendEquationRgb = state.blendEquationAlpha = glArgs[0]; + }, + + /** + * Called immediately *before* 'blendEquationSeparate' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + blendEquationSeparate: function (glArgs, cache) { + let state = cache.currentState; + state.blendEquationRgb = glArgs[0]; + state.blendEquationAlpha = glArgs[1]; + }, + + /** + * Called immediately *before* 'blendFunc' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + blendFunc: function (glArgs, cache) { + let state = cache.currentState; + state.blendSrcRgb = state.blendSrcAlpha = glArgs[0]; + state.blendDstRgb = state.blendDstAlpha = glArgs[1]; + }, + + /** + * Called immediately *before* 'blendFuncSeparate' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + blendFuncSeparate: function (glArgs, cache) { + let state = cache.currentState; + state.blendSrcRgb = glArgs[0]; + state.blendDstRgb = glArgs[1]; + state.blendSrcAlpha = glArgs[2]; + state.blendDstAlpha = glArgs[3]; + }, + + /** + * Called immediately *before* 'drawArrays' or 'drawElements' is requested + * in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + * @param WebGLProxy proxy + * The proxy methods for the WebGL context initiating this call. + */ + beforeDraw_: function (glArgs, cache, proxy) { + let traits = cache.currentProgramTraits; + + // Handle program blackboxing. + if (traits & PROGRAM_BLACKBOX_TRAIT) { + return true; // Return true to break original function call. + } + // Handle program highlighting. + if (traits & PROGRAM_HIGHLIGHT_TRAIT) { + proxy.enableHighlighting(); + } + + return false; + }, + + /** + * Called immediately *after* 'drawArrays' or 'drawElements' is requested + * in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param void glResult + * The returned value of the original function call. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + * @param WebGLProxy proxy + * The proxy methods for the WebGL context initiating this call. + */ + afterDraw_: function (glArgs, glResult, cache, proxy) { + let traits = cache.currentProgramTraits; + + // Handle program highlighting. + if (traits & PROGRAM_HIGHLIGHT_TRAIT) { + proxy.disableHighlighting(); + } + } +}; + +/** + * A mechanism for storing a single WebGL context's state, programs, shaders, + * attributes or uniforms. + * + * @param number id + * The id of the window containing the WebGL context. + * @param WebGLRenderingContext context + * The WebGL context for which the state is stored. + */ +function WebGLCache(id, context) { + this._id = id; + this._gl = context; + this._programs = new Map(); + this.currentState = {}; +} + +WebGLCache.prototype = { + _id: 0, + _gl: null, + _programs: null, + _currentProgramInfo: null, + _currentAttributesMap: null, + _currentUniformsMap: null, + + get ownerWindow() { + return this._id; + }, + + get ownerContext() { + return this._gl; + }, + + /** + * A collection of flags or properties representing the context's state. + * Implemented as an object hash and not a Map instance because keys are + * always either strings or numbers. + */ + currentState: null, + + /** + * Populates the current state with values retrieved from the context. + * + * @param WebGLProxy proxy + * The proxy methods for the WebGL context owning the state. + */ + refreshState: function (proxy) { + let gl = this._gl; + let s = this.currentState; + + // Populate only with the necessary parameters. Not all default WebGL + // state values are required. + s[gl.BLEND] = proxy.isEnabled("BLEND"); + s.blendColor = proxy.getParameter("BLEND_COLOR"); + s.blendEquationRgb = proxy.getParameter("BLEND_EQUATION_RGB"); + s.blendEquationAlpha = proxy.getParameter("BLEND_EQUATION_ALPHA"); + s.blendSrcRgb = proxy.getParameter("BLEND_SRC_RGB"); + s.blendSrcAlpha = proxy.getParameter("BLEND_SRC_ALPHA"); + s.blendDstRgb = proxy.getParameter("BLEND_DST_RGB"); + s.blendDstAlpha = proxy.getParameter("BLEND_DST_ALPHA"); + }, + + /** + * Adds a program to the cache. + * + * @param WebGLProgram program + * The shader for which the traits are to be cached. + * @param number traits + * A default properties mask set for the program. + */ + addProgram: function (program, traits) { + this._programs.set(program, { + traits: traits, + attributes: [], // keys are GLints (numbers) + uniforms: new Map() // keys are WebGLUniformLocations (objects) + }); + }, + + /** + * Adds a specific trait to a program. The effect of such properties is + * determined by the consumer of this cache. + * + * @param WebGLProgram program + * The program to add the trait to. + * @param number trait + * The property added to the program. + */ + setProgramTrait: function (program, trait) { + this._programs.get(program).traits |= trait; + }, + + /** + * Removes a specific trait from a program. + * + * @param WebGLProgram program + * The program to remove the trait from. + * @param number trait + * The property removed from the program. + */ + unsetProgramTrait: function (program, trait) { + this._programs.get(program).traits &= ~trait; + }, + + /** + * Sets the currently used program in the context. + * @param WebGLProgram program + */ + set currentProgram(program) { + let programInfo = this._programs.get(program); + if (programInfo == null) { + return; + } + this._currentProgramInfo = programInfo; + this._currentAttributesMap = programInfo.attributes; + this._currentUniformsMap = programInfo.uniforms; + }, + + /** + * Gets the traits for the currently used program. + * @return number + */ + get currentProgramTraits() { + return this._currentProgramInfo.traits; + }, + + /** + * Adds an attribute to the cache. + * + * @param WebGLProgram program + * The program for which the attribute is bound. + * @param string name + * The attribute name. + * @param GLint value + * The attribute value. + */ + addAttribute: function (program, name, value) { + this._programs.get(program).attributes[value] = { + name: name, + value: value + }; + }, + + /** + * Adds a uniform to the cache. + * + * @param WebGLProgram program + * The program for which the uniform is bound. + * @param string name + * The uniform name. + * @param WebGLUniformLocation value + * The uniform value. + */ + addUniform: function (program, name, value) { + this._programs.get(program).uniforms.set(new XPCNativeWrapper(value), { + name: name, + value: value + }); + }, + + /** + * Updates the attribute locations for a specific program. + * This is necessary, for example, when the shader is relinked and all the + * attribute locations become obsolete. + * + * @param WebGLProgram program + * The program for which the attributes need updating. + */ + updateAttributesForProgram: function (program) { + let attributes = this._programs.get(program).attributes; + for (let attribute of attributes) { + attribute.value = this._gl.getAttribLocation(program, attribute.name); + } + }, + + /** + * Updates the uniform locations for a specific program. + * This is necessary, for example, when the shader is relinked and all the + * uniform locations become obsolete. + * + * @param WebGLProgram program + * The program for which the uniforms need updating. + */ + updateUniformsForProgram: function (program) { + let uniforms = this._programs.get(program).uniforms; + for (let [, uniform] of uniforms) { + uniform.value = this._gl.getUniformLocation(program, uniform.name); + } + }, + + /** + * Gets the actual attribute location in a specific program. + * When relinked, all the attribute locations become obsolete and are updated + * in the cache. This method returns the (current) real attribute location. + * + * @param GLint initialValue + * The initial attribute value. + * @return GLint + * The current attribute value, or the initial value if it's already + * up to date with its corresponding program. + */ + getCurrentAttributeLocation: function (initialValue) { + let attributes = this._currentAttributesMap; + let currentInfo = attributes ? attributes[initialValue] : null; + return currentInfo ? currentInfo.value : initialValue; + }, + + /** + * Gets the actual uniform location in a specific program. + * When relinked, all the uniform locations become obsolete and are updated + * in the cache. This method returns the (current) real uniform location. + * + * @param WebGLUniformLocation initialValue + * The initial uniform value. + * @return WebGLUniformLocation + * The current uniform value, or the initial value if it's already + * up to date with its corresponding program. + */ + getCurrentUniformLocation: function (initialValue) { + let uniforms = this._currentUniformsMap; + let currentInfo = uniforms ? uniforms.get(initialValue) : null; + return currentInfo ? currentInfo.value : initialValue; + } +}; + +/** + * A mechanism for injecting or qureying state into/from a single WebGL context. + * + * Any interaction with a WebGL context should go through this proxy. + * Otherwise, the corresponding observer would register the calls as coming + * from content, which is usually not desirable. Infinite call stacks are bad. + * + * @param number id + * The id of the window containing the WebGL context. + * @param WebGLRenderingContext context + * The WebGL context used for the proxy methods. + * @param WebGLCache cache + * The state storage for the corresponding context. + * @param WebGLObserver observer + * The observer watching function calls in the corresponding context. + */ +function WebGLProxy(id, context, cache, observer) { + this._id = id; + this._gl = context; + this._cache = cache; + this._observer = observer; + + let exports = [ + "isEnabled", + "getParameter", + "getAttachedShaders", + "getShaderSource", + "getShaderOfType", + "compileShader", + "enableHighlighting", + "disableHighlighting", + "readPixels" + ]; + exports.forEach(e => this[e] = (...args) => this._call(e, args)); +} + +WebGLProxy.prototype = { + _id: 0, + _gl: null, + _cache: null, + _observer: null, + + get ownerWindow() { + return this._id; + }, + get ownerContext() { + return this._gl; + }, + + /** + * Test whether a WebGL capability is enabled. + * + * @param string name + * The WebGL capability name, for example "BLEND". + * @return boolean + * True if enabled, false otherwise. + */ + _isEnabled: function (name) { + return this._gl.isEnabled(this._gl[name]); + }, + + /** + * Returns the value for the specified WebGL parameter name. + * + * @param string name + * The WebGL parameter name, for example "BLEND_COLOR". + * @return any + * The corresponding parameter's value. + */ + _getParameter: function (name) { + return this._gl.getParameter(this._gl[name]); + }, + + /** + * Returns the renderbuffer property value for the specified WebGL parameter. + * If no renderbuffer binding is available, null is returned. + * + * @param string name + * The WebGL parameter name, for example "BLEND_COLOR". + * @return any + * The corresponding parameter's value. + */ + _getRenderbufferParameter: function (name) { + if (!this._getParameter("RENDERBUFFER_BINDING")) { + return null; + } + let gl = this._gl; + return gl.getRenderbufferParameter(gl.RENDERBUFFER, gl[name]); + }, + + /** + * Returns the framebuffer property value for the specified WebGL parameter. + * If no framebuffer binding is available, null is returned. + * + * @param string type + * The framebuffer object attachment point, for example "COLOR_ATTACHMENT0". + * @param string name + * The WebGL parameter name, for example "FRAMEBUFFER_ATTACHMENT_OBJECT_NAME". + * If unspecified, defaults to "FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE". + * @return any + * The corresponding parameter's value. + */ + _getFramebufferAttachmentParameter: function (type, name = "FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE") { + if (!this._getParameter("FRAMEBUFFER_BINDING")) { + return null; + } + let gl = this._gl; + return gl.getFramebufferAttachmentParameter(gl.FRAMEBUFFER, gl[type], gl[name]); + }, + + /** + * Returns the shader objects attached to a program object. + * + * @param WebGLProgram program + * The program for which to retrieve the attached shaders. + * @return array + * The attached vertex and fragment shaders. + */ + _getAttachedShaders: function (program) { + return this._gl.getAttachedShaders(program); + }, + + /** + * Returns the source code string from a shader object. + * + * @param WebGLShader shader + * The shader for which to retrieve the source code. + * @return string + * The shader's source code. + */ + _getShaderSource: function (shader) { + return this._gl.getShaderSource(shader); + }, + + /** + * Finds a shader of the specified type in a list. + * + * @param WebGLShader[] shaders + * The shaders for which to check the type. + * @param string type + * Either "vertex" or "fragment". + * @return WebGLShader | null + * The shader of the specified type, or null if nothing is found. + */ + _getShaderOfType: function (shaders, type) { + let gl = this._gl; + let shaderTypeEnum = { + vertex: gl.VERTEX_SHADER, + fragment: gl.FRAGMENT_SHADER + }[type]; + + for (let shader of shaders) { + if (gl.getShaderParameter(shader, gl.SHADER_TYPE) == shaderTypeEnum) { + return shader; + } + } + return null; + }, + + /** + * Changes a shader's source code and relinks the respective program. + * + * @param WebGLProgram program + * The program who's linked shader is to be modified. + * @param WebGLShader shader + * The shader to be modified. + * @param string text + * The new shader source code. + * @return object + * An object containing the compilation and linking status. + */ + _compileShader: function (program, shader, text) { + let gl = this._gl; + gl.shaderSource(shader, text); + gl.compileShader(shader); + gl.linkProgram(program); + + let error = { compile: "", link: "" }; + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + error.compile = gl.getShaderInfoLog(shader); + } + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + error.link = gl.getShaderInfoLog(shader); + } + + this._cache.updateAttributesForProgram(program); + this._cache.updateUniformsForProgram(program); + + return error; + }, + + /** + * Enables color blending based on the geometry highlight tint. + */ + _enableHighlighting: function () { + let gl = this._gl; + + // Avoid changing the blending params when "rendering to texture". + + // Check drawing to a custom framebuffer bound to the default renderbuffer. + let hasFramebuffer = this._getParameter("FRAMEBUFFER_BINDING"); + let hasRenderbuffer = this._getParameter("RENDERBUFFER_BINDING"); + if (hasFramebuffer && !hasRenderbuffer) { + return; + } + + // Check drawing to a depth or stencil component of the framebuffer. + let writesDepth = this._getFramebufferAttachmentParameter("DEPTH_ATTACHMENT"); + let writesStencil = this._getFramebufferAttachmentParameter("STENCIL_ATTACHMENT"); + if (writesDepth || writesStencil) { + return; + } + + // Non-premultiplied alpha blending based on a predefined constant color. + // Simply using gl.colorMask won't work, because we want non-tinted colors + // to be drawn as black, not ignored. + gl.enable(gl.BLEND); + gl.blendColor.apply(gl, this.highlightTint); + gl.blendEquation(gl.FUNC_ADD); + gl.blendFunc(gl.CONSTANT_COLOR, gl.ONE_MINUS_SRC_ALPHA, gl.CONSTANT_COLOR, gl.ZERO); + this.wasHighlighting = true; + }, + + /** + * Disables color blending based on the geometry highlight tint, by + * reverting the corresponding params back to their original values. + */ + _disableHighlighting: function () { + let gl = this._gl; + let s = this._cache.currentState; + + gl[s[gl.BLEND] ? "enable" : "disable"](gl.BLEND); + gl.blendColor.apply(gl, s.blendColor); + gl.blendEquationSeparate(s.blendEquationRgb, s.blendEquationAlpha); + gl.blendFuncSeparate(s.blendSrcRgb, s.blendDstRgb, s.blendSrcAlpha, s.blendDstAlpha); + }, + + /** + * Returns the pixel values at the position specified on the canvas. + */ + _readPixels: function (x, y, w, h, format, type, buffer) { + this._gl.readPixels(x, y, w, h, format, type, buffer); + }, + + /** + * The color tint used for highlighting geometry. + * @see _enableHighlighting and _disableHighlighting. + */ + highlightTint: [0, 0, 0, 0], + + /** + * Executes a function in this object. + * + * This method makes sure that any handlers in the context observer are + * suppressed, hence stopping observing any context function calls. + * + * @param string funcName + * The function to call. + * @param array args + * An array of arguments. + * @return any + * The called function result. + */ + _call: function (funcName, args) { + let prevState = this._observer.suppressHandlers; + + this._observer.suppressHandlers = true; + let result = this["_" + funcName].apply(this, args); + this._observer.suppressHandlers = prevState; + + return result; + } +}; + +// Utility functions. + +function removeFromMap(map, predicate) { + for (let [key, value] of map) { + if (predicate(value)) { + map.delete(key); + } + } +} + +function removeFromArray(array, predicate) { + for (let i = 0; i < array.length;) { + if (predicate(array[i])) { + array.splice(i, 1); + } else { + i++; + } + } +} diff --git a/devtools/server/actors/worker.js b/devtools/server/actors/worker.js new file mode 100644 index 000000000..1937229d5 --- /dev/null +++ b/devtools/server/actors/worker.js @@ -0,0 +1,611 @@ +/* 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 { DebuggerServer } = require("devtools/server/main"); +const Services = require("Services"); +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); +const protocol = require("devtools/shared/protocol"); +const { Arg, method, RetVal } = protocol; +const { + workerSpec, + pushSubscriptionSpec, + serviceWorkerRegistrationSpec, + serviceWorkerSpec, +} = require("devtools/shared/specs/worker"); + +loader.lazyRequireGetter(this, "ChromeUtils"); +loader.lazyRequireGetter(this, "events", "sdk/event/core"); + +XPCOMUtils.defineLazyServiceGetter( + this, "wdm", + "@mozilla.org/dom/workers/workerdebuggermanager;1", + "nsIWorkerDebuggerManager" +); + +XPCOMUtils.defineLazyServiceGetter( + this, "swm", + "@mozilla.org/serviceworkers/manager;1", + "nsIServiceWorkerManager" +); + +XPCOMUtils.defineLazyServiceGetter( + this, "PushService", + "@mozilla.org/push/Service;1", + "nsIPushService" +); + +function matchWorkerDebugger(dbg, options) { + if ("type" in options && dbg.type !== options.type) { + return false; + } + if ("window" in options) { + let window = dbg.window; + while (window !== null && window.parent !== window) { + window = window.parent; + } + + if (window !== options.window) { + return false; + } + } + + return true; +} + +let WorkerActor = protocol.ActorClassWithSpec(workerSpec, { + initialize(conn, dbg) { + protocol.Actor.prototype.initialize.call(this, conn); + this._dbg = dbg; + this._attached = false; + this._threadActor = null; + this._transport = null; + }, + + form(detail) { + if (detail === "actorid") { + return this.actorID; + } + let form = { + actor: this.actorID, + consoleActor: this._consoleActor, + url: this._dbg.url, + type: this._dbg.type + }; + if (this._dbg.type === Ci.nsIWorkerDebugger.TYPE_SERVICE) { + let registration = this._getServiceWorkerRegistrationInfo(); + form.scope = registration.scope; + } + return form; + }, + + attach() { + if (this._dbg.isClosed) { + return { error: "closed" }; + } + + if (!this._attached) { + // Automatically disable their internal timeout that shut them down + // Should be refactored by having actors specific to service workers + if (this._dbg.type == Ci.nsIWorkerDebugger.TYPE_SERVICE) { + let worker = this._getServiceWorkerInfo(); + if (worker) { + worker.attachDebugger(); + } + } + this._dbg.addListener(this); + this._attached = true; + } + + return { + type: "attached", + url: this._dbg.url + }; + }, + + detach() { + if (!this._attached) { + return { error: "wrongState" }; + } + + this._detach(); + + return { type: "detached" }; + }, + + destroy() { + protocol.Actor.prototype.destroy.call(this); + if (this._attached) { + this._detach(); + } + }, + + disconnect() { + this.destroy(); + }, + + connect(options) { + if (!this._attached) { + return { error: "wrongState" }; + } + + if (this._threadActor !== null) { + return { + type: "connected", + threadActor: this._threadActor + }; + } + + return DebuggerServer.connectToWorker( + this.conn, this._dbg, this.actorID, options + ).then(({ threadActor, transport, consoleActor }) => { + this._threadActor = threadActor; + this._transport = transport; + this._consoleActor = consoleActor; + + return { + type: "connected", + threadActor: this._threadActor, + consoleActor: this._consoleActor + }; + }, (error) => { + return { error: error.toString() }; + }); + }, + + push() { + if (this._dbg.type !== Ci.nsIWorkerDebugger.TYPE_SERVICE) { + return { error: "wrongType" }; + } + let registration = this._getServiceWorkerRegistrationInfo(); + let originAttributes = ChromeUtils.originAttributesToSuffix( + this._dbg.principal.originAttributes); + swm.sendPushEvent(originAttributes, registration.scope); + return { type: "pushed" }; + }, + + onClose() { + if (this._attached) { + this._detach(); + } + + this.conn.sendActorEvent(this.actorID, "close"); + }, + + onError(filename, lineno, message) { + reportError("ERROR:" + filename + ":" + lineno + ":" + message + "\n"); + }, + + _getServiceWorkerRegistrationInfo() { + return swm.getRegistrationByPrincipal(this._dbg.principal, this._dbg.url); + }, + + _getServiceWorkerInfo() { + let registration = this._getServiceWorkerRegistrationInfo(); + return registration.getWorkerByID(this._dbg.serviceWorkerID); + }, + + _detach() { + if (this._threadActor !== null) { + this._transport.close(); + this._transport = null; + this._threadActor = null; + } + + // If the worker is already destroyed, nsIWorkerDebugger.type throws + // (_dbg.closed appears to be false when it throws) + let type; + try { + type = this._dbg.type; + } catch (e) {} + + if (type == Ci.nsIWorkerDebugger.TYPE_SERVICE) { + let worker = this._getServiceWorkerInfo(); + if (worker) { + worker.detachDebugger(); + } + } + + this._dbg.removeListener(this); + this._attached = false; + } +}); + +exports.WorkerActor = WorkerActor; + +function WorkerActorList(conn, options) { + this._conn = conn; + this._options = options; + this._actors = new Map(); + this._onListChanged = null; + this._mustNotify = false; + this.onRegister = this.onRegister.bind(this); + this.onUnregister = this.onUnregister.bind(this); +} + +WorkerActorList.prototype = { + getList() { + // Create a set of debuggers. + let dbgs = new Set(); + let e = wdm.getWorkerDebuggerEnumerator(); + while (e.hasMoreElements()) { + let dbg = e.getNext().QueryInterface(Ci.nsIWorkerDebugger); + if (matchWorkerDebugger(dbg, this._options)) { + dbgs.add(dbg); + } + } + + // Delete each actor for which we don't have a debugger. + for (let [dbg, ] of this._actors) { + if (!dbgs.has(dbg)) { + this._actors.delete(dbg); + } + } + + // Create an actor for each debugger for which we don't have one. + for (let dbg of dbgs) { + if (!this._actors.has(dbg)) { + this._actors.set(dbg, new WorkerActor(this._conn, dbg)); + } + } + + let actors = []; + for (let [, actor] of this._actors) { + actors.push(actor); + } + + if (!this._mustNotify) { + if (this._onListChanged !== null) { + wdm.addListener(this); + } + this._mustNotify = true; + } + + return Promise.resolve(actors); + }, + + get onListChanged() { + return this._onListChanged; + }, + + set onListChanged(onListChanged) { + if (typeof onListChanged !== "function" && onListChanged !== null) { + throw new Error("onListChanged must be either a function or null."); + } + if (onListChanged === this._onListChanged) { + return; + } + + if (this._mustNotify) { + if (this._onListChanged === null && onListChanged !== null) { + wdm.addListener(this); + } + if (this._onListChanged !== null && onListChanged === null) { + wdm.removeListener(this); + } + } + this._onListChanged = onListChanged; + }, + + _notifyListChanged() { + this._onListChanged(); + + if (this._onListChanged !== null) { + wdm.removeListener(this); + } + this._mustNotify = false; + }, + + onRegister(dbg) { + if (matchWorkerDebugger(dbg, this._options)) { + this._notifyListChanged(); + } + }, + + onUnregister(dbg) { + if (matchWorkerDebugger(dbg, this._options)) { + this._notifyListChanged(); + } + } +}; + +exports.WorkerActorList = WorkerActorList; + +let PushSubscriptionActor = protocol.ActorClassWithSpec(pushSubscriptionSpec, { + initialize(conn, subscription) { + protocol.Actor.prototype.initialize.call(this, conn); + this._subscription = subscription; + }, + + form(detail) { + if (detail === "actorid") { + return this.actorID; + } + let subscription = this._subscription; + return { + actor: this.actorID, + endpoint: subscription.endpoint, + pushCount: subscription.pushCount, + lastPush: subscription.lastPush, + quota: subscription.quota + }; + }, + + destroy() { + protocol.Actor.prototype.destroy.call(this); + this._subscription = null; + }, +}); + +let ServiceWorkerActor = protocol.ActorClassWithSpec(serviceWorkerSpec, { + initialize(conn, worker) { + protocol.Actor.prototype.initialize.call(this, conn); + this._worker = worker; + }, + + form() { + if (!this._worker) { + return null; + } + + return { + url: this._worker.scriptSpec, + state: this._worker.state, + }; + }, + + destroy() { + protocol.Actor.prototype.destroy.call(this); + this._worker = null; + }, +}); + +// Lazily load the service-worker-child.js process script only once. +let _serviceWorkerProcessScriptLoaded = false; + +let ServiceWorkerRegistrationActor = +protocol.ActorClassWithSpec(serviceWorkerRegistrationSpec, { + /** + * Create the ServiceWorkerRegistrationActor + * @param DebuggerServerConnection conn + * The server connection. + * @param ServiceWorkerRegistrationInfo registration + * The registration's information. + */ + initialize(conn, registration) { + protocol.Actor.prototype.initialize.call(this, conn); + this._conn = conn; + this._registration = registration; + this._pushSubscriptionActor = null; + this._registration.addListener(this); + + let {installingWorker, waitingWorker, activeWorker} = registration; + this._installingWorker = new ServiceWorkerActor(conn, installingWorker); + this._waitingWorker = new ServiceWorkerActor(conn, waitingWorker); + this._activeWorker = new ServiceWorkerActor(conn, activeWorker); + + Services.obs.addObserver(this, PushService.subscriptionModifiedTopic, false); + }, + + onChange() { + this._installingWorker.destroy(); + this._waitingWorker.destroy(); + this._activeWorker.destroy(); + + let {installingWorker, waitingWorker, activeWorker} = this._registration; + this._installingWorker = new ServiceWorkerActor(this._conn, installingWorker); + this._waitingWorker = new ServiceWorkerActor(this._conn, waitingWorker); + this._activeWorker = new ServiceWorkerActor(this._conn, activeWorker); + + events.emit(this, "registration-changed"); + }, + + form(detail) { + if (detail === "actorid") { + return this.actorID; + } + let registration = this._registration; + let installingWorker = this._installingWorker.form(); + let waitingWorker = this._waitingWorker.form(); + let activeWorker = this._activeWorker.form(); + + let isE10s = Services.appinfo.browserTabsRemoteAutostart; + return { + actor: this.actorID, + scope: registration.scope, + url: registration.scriptSpec, + installingWorker, + waitingWorker, + activeWorker, + // - In e10s: only active registrations are available. + // - In non-e10s: registrations always have at least one worker, if the worker is + // active, the registration is active. + active: isE10s ? true : !!activeWorker + }; + }, + + destroy() { + protocol.Actor.prototype.destroy.call(this); + Services.obs.removeObserver(this, PushService.subscriptionModifiedTopic, false); + this._registration.removeListener(this); + this._registration = null; + if (this._pushSubscriptionActor) { + this._pushSubscriptionActor.destroy(); + } + this._pushSubscriptionActor = null; + + this._installingWorker.destroy(); + this._waitingWorker.destroy(); + this._activeWorker.destroy(); + + this._installingWorker = null; + this._waitingWorker = null; + this._activeWorker = null; + }, + + disconnect() { + this.destroy(); + }, + + /** + * Standard observer interface to listen to push messages and changes. + */ + observe(subject, topic, data) { + let scope = this._registration.scope; + if (data !== scope) { + // This event doesn't concern us, pretend nothing happened. + return; + } + switch (topic) { + case PushService.subscriptionModifiedTopic: + if (this._pushSubscriptionActor) { + this._pushSubscriptionActor.destroy(); + this._pushSubscriptionActor = null; + } + events.emit(this, "push-subscription-modified"); + break; + } + }, + + start() { + if (!_serviceWorkerProcessScriptLoaded) { + Services.ppmm.loadProcessScript( + "resource://devtools/server/service-worker-child.js", true); + _serviceWorkerProcessScriptLoaded = true; + } + Services.ppmm.broadcastAsyncMessage("serviceWorkerRegistration:start", { + scope: this._registration.scope + }); + return { type: "started" }; + }, + + unregister() { + let { principal, scope } = this._registration; + let unregisterCallback = { + unregisterSucceeded: function () {}, + unregisterFailed: function () { + console.error("Failed to unregister the service worker for " + scope); + }, + QueryInterface: XPCOMUtils.generateQI( + [Ci.nsIServiceWorkerUnregisterCallback]) + }; + swm.propagateUnregister(principal, unregisterCallback, scope); + + return { type: "unregistered" }; + }, + + getPushSubscription() { + let registration = this._registration; + let pushSubscriptionActor = this._pushSubscriptionActor; + if (pushSubscriptionActor) { + return Promise.resolve(pushSubscriptionActor); + } + return new Promise((resolve, reject) => { + PushService.getSubscription( + registration.scope, + registration.principal, + (result, subscription) => { + if (!subscription) { + resolve(null); + return; + } + pushSubscriptionActor = new PushSubscriptionActor(this._conn, subscription); + this._pushSubscriptionActor = pushSubscriptionActor; + resolve(pushSubscriptionActor); + } + ); + }); + }, +}); + +function ServiceWorkerRegistrationActorList(conn) { + this._conn = conn; + this._actors = new Map(); + this._onListChanged = null; + this._mustNotify = false; + this.onRegister = this.onRegister.bind(this); + this.onUnregister = this.onUnregister.bind(this); +} + +ServiceWorkerRegistrationActorList.prototype = { + getList() { + // Create a set of registrations. + let registrations = new Set(); + let array = swm.getAllRegistrations(); + for (let index = 0; index < array.length; ++index) { + registrations.add( + array.queryElementAt(index, Ci.nsIServiceWorkerRegistrationInfo)); + } + + // Delete each actor for which we don't have a registration. + for (let [registration, ] of this._actors) { + if (!registrations.has(registration)) { + this._actors.delete(registration); + } + } + + // Create an actor for each registration for which we don't have one. + for (let registration of registrations) { + if (!this._actors.has(registration)) { + this._actors.set(registration, + new ServiceWorkerRegistrationActor(this._conn, registration)); + } + } + + if (!this._mustNotify) { + if (this._onListChanged !== null) { + swm.addListener(this); + } + this._mustNotify = true; + } + + let actors = []; + for (let [, actor] of this._actors) { + actors.push(actor); + } + + return Promise.resolve(actors); + }, + + get onListchanged() { + return this._onListchanged; + }, + + set onListChanged(onListChanged) { + if (typeof onListChanged !== "function" && onListChanged !== null) { + throw new Error("onListChanged must be either a function or null."); + } + + if (this._mustNotify) { + if (this._onListChanged === null && onListChanged !== null) { + swm.addListener(this); + } + if (this._onListChanged !== null && onListChanged === null) { + swm.removeListener(this); + } + } + this._onListChanged = onListChanged; + }, + + _notifyListChanged() { + this._onListChanged(); + + if (this._onListChanged !== null) { + swm.removeListener(this); + } + this._mustNotify = false; + }, + + onRegister(registration) { + this._notifyListChanged(); + }, + + onUnregister(registration) { + this._notifyListChanged(); + } +}; + +exports.ServiceWorkerRegistrationActorList = ServiceWorkerRegistrationActorList; diff --git a/devtools/server/child.js b/devtools/server/child.js new file mode 100644 index 000000000..e2838f08d --- /dev/null +++ b/devtools/server/child.js @@ -0,0 +1,127 @@ +/* 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"; + +/* global addEventListener, addMessageListener, removeMessageListener, sendAsyncMessage */ + +try { + var chromeGlobal = this; + + // Encapsulate in its own scope to allows loading this frame script more than once. + (function () { + const Cu = Components.utils; + const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); + + const DevToolsUtils = require("devtools/shared/DevToolsUtils"); + const { dumpn } = DevToolsUtils; + const { DebuggerServer, ActorPool } = require("devtools/server/main"); + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + // For non-e10s mode, there is only one server instance, so be sure the browser + // actors get loaded. + DebuggerServer.addBrowserActors(); + } + + // In case of apps being loaded in parent process, DebuggerServer is already + // initialized, but child specific actors are not registered. Otherwise, for apps in + // child process, we need to load actors the first time we load child.js. + DebuggerServer.addChildActors(); + + let connections = new Map(); + + let onConnect = DevToolsUtils.makeInfallible(function (msg) { + removeMessageListener("debug:connect", onConnect); + + let mm = msg.target; + let prefix = msg.data.prefix; + + let conn = DebuggerServer.connectToParent(prefix, mm); + conn.parentMessageManager = mm; + connections.set(prefix, conn); + + let actor = new DebuggerServer.ContentActor(conn, chromeGlobal, prefix); + let actorPool = new ActorPool(conn); + actorPool.addActor(actor); + conn.addActorPool(actorPool); + + sendAsyncMessage("debug:actor", {actor: actor.form(), prefix: prefix}); + }); + + addMessageListener("debug:connect", onConnect); + + // Allows executing module setup helper from the parent process. + // See also: DebuggerServer.setupInChild() + let onSetupInChild = DevToolsUtils.makeInfallible(msg => { + let { module, setupChild, args } = msg.data; + let m; + + try { + m = require(module); + + if (!setupChild in m) { + dumpn(`ERROR: module '${module}' does not export '${setupChild}'`); + return false; + } + + m[setupChild].apply(m, args); + } catch (e) { + let errorMessage = + "Exception during actor module setup running in the child process: "; + DevToolsUtils.reportException(errorMessage + e); + dumpn(`ERROR: ${errorMessage}\n\t module: '${module}'\n\t ` + + `setupChild: '${setupChild}'\n${DevToolsUtils.safeErrorString(e)}`); + return false; + } + if (msg.data.id) { + // Send a message back to know when it is processed + sendAsyncMessage("debug:setup-in-child-response", {id: msg.data.id}); + } + return true; + }); + + addMessageListener("debug:setup-in-child", onSetupInChild); + + let onDisconnect = DevToolsUtils.makeInfallible(function (msg) { + let prefix = msg.data.prefix; + let conn = connections.get(prefix); + if (!conn) { + // Several copies of this frame script can be running for a single frame since it + // is loaded once for each DevTools connection to the frame. If this disconnect + // request doesn't match a connection known here, ignore it. + return; + } + + removeMessageListener("debug:disconnect", onDisconnect); + // Call DebuggerServerConnection.close to destroy all child actors. It should end up + // calling DebuggerServerConnection.onClosed that would actually cleanup all actor + // pools. + conn.close(); + connections.delete(prefix); + }); + addMessageListener("debug:disconnect", onDisconnect); + + // In non-e10s mode, the "debug:disconnect" message isn't always received before the + // messageManager connection goes away. Watching for "unload" here ensures we close + // any connections when the frame is unloaded. + addEventListener("unload", () => { + for (let conn of connections.values()) { + conn.close(); + } + connections.clear(); + }); + + let onInspect = DevToolsUtils.makeInfallible(function (msg) { + // Store the node to be inspected in a global variable (gInspectingNode). Later + // we'll fetch this variable again using the findInspectingNode request over the + // remote debugging protocol. + let inspector = require("devtools/server/actors/inspector"); + inspector.setInspectingNode(msg.objects.node); + }); + addMessageListener("debug:inspect", onInspect); + })(); +} catch (e) { + dump(`Exception in app child process: ${e}\n`); +} diff --git a/devtools/server/content-globals.js b/devtools/server/content-globals.js new file mode 100644 index 000000000..2c59552e7 --- /dev/null +++ b/devtools/server/content-globals.js @@ -0,0 +1,47 @@ +/* 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 Services = require("Services"); + +var globalsCache = {}; + +exports.addContentGlobal = function (options) { + if (!options || !options.global || !options["inner-window-id"]) { + throw Error("Invalid arguments"); + } + let cache = getGlobalCache(options["inner-window-id"]); + cache.push(options.global); + return undefined; +}; + +exports.getContentGlobals = function (options) { + if (!options || !options["inner-window-id"]) { + throw Error("Invalid arguments"); + } + return Array.slice(globalsCache[options["inner-window-id"]] || []); +}; + +exports.removeContentGlobal = function (options) { + if (!options || !options.global || !options["inner-window-id"]) { + throw Error("Invalid arguments"); + } + let cache = getGlobalCache(options["inner-window-id"]); + let index = cache.indexOf(options.global); + cache.splice(index, 1); + return undefined; +}; + +function getGlobalCache(aInnerWindowID) { + return globalsCache[aInnerWindowID] = globalsCache[aInnerWindowID] || []; +} + +// when the window is destroyed, eliminate the associated globals cache +if (!isWorker) { + Services.obs.addObserver(function observer(subject, topic, data) { + let id = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + delete globalsCache[id]; + }, "inner-window-destroyed", false); +} diff --git a/devtools/server/content-server.jsm b/devtools/server/content-server.jsm new file mode 100644 index 000000000..03c727585 --- /dev/null +++ b/devtools/server/content-server.jsm @@ -0,0 +1,56 @@ +/* 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 = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; + +const { Services } = Cu.import("resource://gre/modules/Services.jsm", {}); +const { DevToolsLoader } = Cu.import("resource://devtools/shared/Loader.jsm", {}); + +this.EXPORTED_SYMBOLS = ["init"]; + +function init(msg) { + // Init a custom, invisible DebuggerServer, in order to not pollute + // the debugger with all devtools modules, nor break the debugger itself with using it + // in the same process. + let devtools = new DevToolsLoader(); + devtools.invisibleToDebugger = true; + let { DebuggerServer, ActorPool } = devtools.require("devtools/server/main"); + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + } + + // In case of apps being loaded in parent process, DebuggerServer is already + // initialized, but child specific actors are not registered. + // Otherwise, for child process, we need to load actors the first + // time we load child.js + DebuggerServer.addChildActors(); + + let mm = msg.target; + mm.QueryInterface(Ci.nsISyncMessageSender); + let prefix = msg.data.prefix; + + // Connect both parent/child processes debugger servers RDP via message managers + let conn = DebuggerServer.connectToParent(prefix, mm); + conn.parentMessageManager = mm; + + let { ChildProcessActor } = devtools.require("devtools/server/actors/child-process"); + let actor = new ChildProcessActor(conn); + let actorPool = new ActorPool(conn); + actorPool.addActor(actor); + conn.addActorPool(actorPool); + + let response = {actor: actor.form()}; + mm.sendAsyncMessage("debug:content-process-actor", response); + + mm.addMessageListener("debug:content-process-destroy", function onDestroy() { + mm.removeMessageListener("debug:content-process-destroy", onDestroy); + + DebuggerServer.destroy(); + }); +} diff --git a/devtools/server/css-logic.js b/devtools/server/css-logic.js new file mode 100644 index 000000000..f632871e1 --- /dev/null +++ b/devtools/server/css-logic.js @@ -0,0 +1,1536 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set 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/. */ + +/* + * About the objects defined in this file: + * - CssLogic contains style information about a view context. It provides + * access to 2 sets of objects: Css[Sheet|Rule|Selector] provide access to + * information that does not change when the selected element changes while + * Css[Property|Selector]Info provide information that is dependent on the + * selected element. + * Its key methods are highlight(), getPropertyInfo() and forEachSheet(), etc + * + * - CssSheet provides a more useful API to a DOM CSSSheet for our purposes, + * including shortSource and href. + * - CssRule a more useful API to a nsIDOMCSSRule including access to the group + * of CssSelectors that the rule provides properties for + * - CssSelector A single selector - i.e. not a selector group. In other words + * a CssSelector does not contain ','. This terminology is different from the + * standard DOM API, but more inline with the definition in the spec. + * + * - CssPropertyInfo contains style information for a single property for the + * highlighted element. + * - CssSelectorInfo is a wrapper around CssSelector, which adds sorting with + * reference to the selected element. + */ + +"use strict"; + +const { Cc, Ci, Cu } = require("chrome"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { getRootBindingParent } = require("devtools/shared/layout/utils"); +const nodeConstants = require("devtools/shared/dom-node-constants"); +const {l10n, isContentStylesheet, shortSource, FILTER, STATUS} = require("devtools/shared/inspector/css-logic"); + +loader.lazyRequireGetter(this, "CSSLexer", "devtools/shared/css/lexer"); + +/** + * @param {function} isInherited A function that determines if the CSS property + * is inherited. + */ +function CssLogic(isInherited) { + // The cache of examined CSS properties. + this._isInherited = isInherited; + this._propertyInfos = {}; +} + +exports.CssLogic = CssLogic; + +CssLogic.prototype = { + // Both setup by highlight(). + viewedElement: null, + viewedDocument: null, + + // The cache of the known sheets. + _sheets: null, + + // Have the sheets been cached? + _sheetsCached: false, + + // The total number of rules, in all stylesheets, after filtering. + _ruleCount: 0, + + // The computed styles for the viewedElement. + _computedStyle: null, + + // Source filter. Only display properties coming from the given source + _sourceFilter: FILTER.USER, + + // Used for tracking unique CssSheet/CssRule/CssSelector objects, in a run of + // processMatchedSelectors(). + _passId: 0, + + // Used for tracking matched CssSelector objects. + _matchId: 0, + + _matchedRules: null, + _matchedSelectors: null, + + // Cached keyframes rules in all stylesheets + _keyframesRules: null, + + /** + * Reset various properties + */ + reset: function () { + this._propertyInfos = {}; + this._ruleCount = 0; + this._sheetIndex = 0; + this._sheets = {}; + this._sheetsCached = false; + this._matchedRules = null; + this._matchedSelectors = null; + this._keyframesRules = []; + }, + + /** + * Focus on a new element - remove the style caches. + * + * @param {nsIDOMElement} aViewedElement the element the user has highlighted + * in the Inspector. + */ + highlight: function (viewedElement) { + if (!viewedElement) { + this.viewedElement = null; + this.viewedDocument = null; + this._computedStyle = null; + this.reset(); + return; + } + + if (viewedElement === this.viewedElement) { + return; + } + + this.viewedElement = viewedElement; + + let doc = this.viewedElement.ownerDocument; + if (doc != this.viewedDocument) { + // New document: clear/rebuild the cache. + this.viewedDocument = doc; + + // Hunt down top level stylesheets, and cache them. + this._cacheSheets(); + } else { + // Clear cached data in the CssPropertyInfo objects. + this._propertyInfos = {}; + } + + this._matchedRules = null; + this._matchedSelectors = null; + this._computedStyle = CssLogic.getComputedStyle(this.viewedElement); + }, + + /** + * Get the values of all the computed CSS properties for the highlighted + * element. + * @returns {object} The computed CSS properties for a selected element + */ + get computedStyle() { + return this._computedStyle; + }, + + /** + * Get the source filter. + * @returns {string} The source filter being used. + */ + get sourceFilter() { + return this._sourceFilter; + }, + + /** + * Source filter. Only display properties coming from the given source (web + * address). Note that in order to avoid information overload we DO NOT show + * unmatched system rules. + * @see FILTER.* + */ + set sourceFilter(value) { + let oldValue = this._sourceFilter; + this._sourceFilter = value; + + let ruleCount = 0; + + // Update the CssSheet objects. + this.forEachSheet(function (sheet) { + sheet._sheetAllowed = -1; + if (sheet.contentSheet && sheet.sheetAllowed) { + ruleCount += sheet.ruleCount; + } + }, this); + + this._ruleCount = ruleCount; + + // Full update is needed because the this.processMatchedSelectors() method + // skips UA stylesheets if the filter does not allow such sheets. + let needFullUpdate = (oldValue == FILTER.UA || value == FILTER.UA); + + if (needFullUpdate) { + this._matchedRules = null; + this._matchedSelectors = null; + this._propertyInfos = {}; + } else { + // Update the CssPropertyInfo objects. + for (let property in this._propertyInfos) { + this._propertyInfos[property].needRefilter = true; + } + } + }, + + /** + * Return a CssPropertyInfo data structure for the currently viewed element + * and the specified CSS property. If there is no currently viewed element we + * return an empty object. + * + * @param {string} property The CSS property to look for. + * @return {CssPropertyInfo} a CssPropertyInfo structure for the given + * property. + */ + getPropertyInfo: function (property) { + if (!this.viewedElement) { + return {}; + } + + let info = this._propertyInfos[property]; + if (!info) { + info = new CssPropertyInfo(this, property, this._isInherited); + this._propertyInfos[property] = info; + } + + return info; + }, + + /** + * Cache all the stylesheets in the inspected document + * @private + */ + _cacheSheets: function () { + this._passId++; + this.reset(); + + // styleSheets isn't an array, but forEach can work on it anyway + Array.prototype.forEach.call(this.viewedDocument.styleSheets, + this._cacheSheet, this); + + this._sheetsCached = true; + }, + + /** + * Cache a stylesheet if it falls within the requirements: if it's enabled, + * and if the @media is allowed. This method also walks through the stylesheet + * cssRules to find @imported rules, to cache the stylesheets of those rules + * as well. In addition, the @keyframes rules in the stylesheet are cached. + * + * @private + * @param {CSSStyleSheet} domSheet the CSSStyleSheet object to cache. + */ + _cacheSheet: function (domSheet) { + if (domSheet.disabled) { + return; + } + + // Only work with stylesheets that have their media allowed. + if (!this.mediaMatches(domSheet)) { + return; + } + + // Cache the sheet. + let cssSheet = this.getSheet(domSheet, this._sheetIndex++); + if (cssSheet._passId != this._passId) { + cssSheet._passId = this._passId; + + // Find import and keyframes rules. + for (let aDomRule of domSheet.cssRules) { + if (aDomRule.type == CSSRule.IMPORT_RULE && + aDomRule.styleSheet && + this.mediaMatches(aDomRule)) { + this._cacheSheet(aDomRule.styleSheet); + } else if (aDomRule.type == CSSRule.KEYFRAMES_RULE) { + this._keyframesRules.push(aDomRule); + } + } + } + }, + + /** + * Retrieve the list of stylesheets in the document. + * + * @return {array} the list of stylesheets in the document. + */ + get sheets() { + if (!this._sheetsCached) { + this._cacheSheets(); + } + + let sheets = []; + this.forEachSheet(function (sheet) { + if (sheet.contentSheet) { + sheets.push(sheet); + } + }, this); + + return sheets; + }, + + /** + * Retrieve the list of keyframes rules in the document. + * + * @ return {array} the list of keyframes rules in the document. + */ + get keyframesRules() { + if (!this._sheetsCached) { + this._cacheSheets(); + } + return this._keyframesRules; + }, + + /** + * Retrieve a CssSheet object for a given a CSSStyleSheet object. If the + * stylesheet is already cached, you get the existing CssSheet object, + * otherwise the new CSSStyleSheet object is cached. + * + * @param {CSSStyleSheet} domSheet the CSSStyleSheet object you want. + * @param {number} index the index, within the document, of the stylesheet. + * + * @return {CssSheet} the CssSheet object for the given CSSStyleSheet object. + */ + getSheet: function (domSheet, index) { + let cacheId = ""; + + if (domSheet.href) { + cacheId = domSheet.href; + } else if (domSheet.ownerNode && domSheet.ownerNode.ownerDocument) { + cacheId = domSheet.ownerNode.ownerDocument.location; + } + + let sheet = null; + let sheetFound = false; + + if (cacheId in this._sheets) { + for (let i = 0, numSheets = this._sheets[cacheId].length; + i < numSheets; + i++) { + sheet = this._sheets[cacheId][i]; + if (sheet.domSheet === domSheet) { + if (index != -1) { + sheet.index = index; + } + sheetFound = true; + break; + } + } + } + + if (!sheetFound) { + if (!(cacheId in this._sheets)) { + this._sheets[cacheId] = []; + } + + sheet = new CssSheet(this, domSheet, index); + if (sheet.sheetAllowed && sheet.contentSheet) { + this._ruleCount += sheet.ruleCount; + } + + this._sheets[cacheId].push(sheet); + } + + return sheet; + }, + + /** + * Process each cached stylesheet in the document using your callback. + * + * @param {function} callback the function you want executed for each of the + * CssSheet objects cached. + * @param {object} scope the scope you want for the callback function. scope + * will be the this object when callback executes. + */ + forEachSheet: function (callback, scope) { + for (let cacheId in this._sheets) { + let sheets = this._sheets[cacheId]; + for (let i = 0; i < sheets.length; i++) { + // We take this as an opportunity to clean dead sheets + try { + let sheet = sheets[i]; + // If accessing domSheet raises an exception, then the style + // sheet is a dead object. + sheet.domSheet; + callback.call(scope, sheet, i, sheets); + } catch (e) { + sheets.splice(i, 1); + i--; + } + } + } + }, + + /** + + /** + * Get the number nsIDOMCSSRule objects in the document, counted from all of + * the stylesheets. System sheets are excluded. If a filter is active, this + * tells only the number of nsIDOMCSSRule objects inside the selected + * CSSStyleSheet. + * + * WARNING: This only provides an estimate of the rule count, and the results + * could change at a later date. Todo remove this + * + * @return {number} the number of nsIDOMCSSRule (all rules). + */ + get ruleCount() { + if (!this._sheetsCached) { + this._cacheSheets(); + } + + return this._ruleCount; + }, + + /** + * Process the CssSelector objects that match the highlighted element and its + * parent elements. scope.callback() is executed for each CssSelector + * object, being passed the CssSelector object and the match status. + * + * This method also includes all of the element.style properties, for each + * highlighted element parent and for the highlighted element itself. + * + * Note that the matched selectors are cached, such that next time your + * callback is invoked for the cached list of CssSelector objects. + * + * @param {function} callback the function you want to execute for each of + * the matched selectors. + * @param {object} scope the scope you want for the callback function. scope + * will be the this object when callback executes. + */ + processMatchedSelectors: function (callback, scope) { + if (this._matchedSelectors) { + if (callback) { + this._passId++; + this._matchedSelectors.forEach(function (value) { + callback.call(scope, value[0], value[1]); + value[0].cssRule._passId = this._passId; + }, this); + } + return; + } + + if (!this._matchedRules) { + this._buildMatchedRules(); + } + + this._matchedSelectors = []; + this._passId++; + + for (let i = 0; i < this._matchedRules.length; i++) { + let rule = this._matchedRules[i][0]; + let status = this._matchedRules[i][1]; + + rule.selectors.forEach(function (selector) { + if (selector._matchId !== this._matchId && + (selector.elementStyle || + this.selectorMatchesElement(rule.domRule, + selector.selectorIndex))) { + selector._matchId = this._matchId; + this._matchedSelectors.push([ selector, status ]); + if (callback) { + callback.call(scope, selector, status); + } + } + }, this); + + rule._passId = this._passId; + } + }, + + /** + * Check if the given selector matches the highlighted element or any of its + * parents. + * + * @private + * @param {DOMRule} domRule + * The DOM Rule containing the selector. + * @param {Number} idx + * The index of the selector within the DOMRule. + * @return {boolean} + * true if the given selector matches the highlighted element or any + * of its parents, otherwise false is returned. + */ + selectorMatchesElement: function (domRule, idx) { + let element = this.viewedElement; + do { + if (domUtils.selectorMatchesElement(element, domRule, idx)) { + return true; + } + } while ((element = element.parentNode) && + element.nodeType === nodeConstants.ELEMENT_NODE); + + return false; + }, + + /** + * Check if the highlighted element or it's parents have matched selectors. + * + * @param {array} aProperties The list of properties you want to check if they + * have matched selectors or not. + * @return {object} An object that tells for each property if it has matched + * selectors or not. Object keys are property names and values are booleans. + */ + hasMatchedSelectors: function (properties) { + if (!this._matchedRules) { + this._buildMatchedRules(); + } + + let result = {}; + + this._matchedRules.some(function (value) { + let rule = value[0]; + let status = value[1]; + properties = properties.filter((property) => { + // We just need to find if a rule has this property while it matches + // the viewedElement (or its parents). + if (rule.getPropertyValue(property) && + (status == STATUS.MATCHED || + (status == STATUS.PARENT_MATCH && + this._isInherited(property)))) { + result[property] = true; + return false; + } + // Keep the property for the next rule. + return true; + }); + return properties.length == 0; + }, this); + + return result; + }, + + /** + * Build the array of matched rules for the currently highlighted element. + * The array will hold rules that match the viewedElement and its parents. + * + * @private + */ + _buildMatchedRules: function () { + let domRules; + let element = this.viewedElement; + let filter = this.sourceFilter; + let sheetIndex = 0; + + this._matchId++; + this._passId++; + this._matchedRules = []; + + if (!element) { + return; + } + + do { + let status = this.viewedElement === element ? + STATUS.MATCHED : STATUS.PARENT_MATCH; + + try { + // Handle finding rules on pseudo by reading style rules + // on the parent node with proper pseudo arg to getCSSStyleRules. + let {bindingElement, pseudo} = + CssLogic.getBindingElementAndPseudo(element); + domRules = domUtils.getCSSStyleRules(bindingElement, pseudo); + } catch (ex) { + console.log("CL__buildMatchedRules error: " + ex); + continue; + } + + // getCSSStyleRules can return null with a shadow DOM element. + let numDomRules = domRules ? domRules.Count() : 0; + for (let i = 0; i < numDomRules; i++) { + let domRule = domRules.GetElementAt(i); + if (domRule.type !== CSSRule.STYLE_RULE) { + continue; + } + + let sheet = this.getSheet(domRule.parentStyleSheet, -1); + if (sheet._passId !== this._passId) { + sheet.index = sheetIndex++; + sheet._passId = this._passId; + } + + if (filter === FILTER.USER && !sheet.contentSheet) { + continue; + } + + let rule = sheet.getRule(domRule); + if (rule._passId === this._passId) { + continue; + } + + rule._matchId = this._matchId; + rule._passId = this._passId; + this._matchedRules.push([rule, status]); + } + + // Add element.style information. + if (element.style && element.style.length > 0) { + let rule = new CssRule(null, { style: element.style }, element); + rule._matchId = this._matchId; + rule._passId = this._passId; + this._matchedRules.push([rule, status]); + } + } while ((element = element.parentNode) && + element.nodeType === nodeConstants.ELEMENT_NODE); + }, + + /** + * Tells if the given DOM CSS object matches the current view media. + * + * @param {object} domObject The DOM CSS object to check. + * @return {boolean} True if the DOM CSS object matches the current view + * media, or false otherwise. + */ + mediaMatches: function (domObject) { + let mediaText = domObject.media.mediaText; + return !mediaText || + this.viewedDocument.defaultView.matchMedia(mediaText).matches; + }, +}; + +/** + * If the element has an id, return '#id'. Otherwise return 'tagname[n]' where + * n is the index of this element in its siblings. + * <p>A technically more 'correct' output from the no-id case might be: + * 'tagname:nth-of-type(n)' however this is unlikely to be more understood + * and it is longer. + * + * @param {nsIDOMElement} element the element for which you want the short name. + * @return {string} the string to be displayed for element. + */ +CssLogic.getShortName = function (element) { + if (!element) { + return "null"; + } + if (element.id) { + return "#" + element.id; + } + let priorSiblings = 0; + let temp = element; + while ((temp = temp.previousElementSibling)) { + priorSiblings++; + } + return element.tagName + "[" + priorSiblings + "]"; +}; + +/** + * Get a string list of selectors for a given DOMRule. + * + * @param {DOMRule} domRule + * The DOMRule to parse. + * @return {Array} + * An array of string selectors. + */ +CssLogic.getSelectors = function (domRule) { + let selectors = []; + + let len = domUtils.getSelectorCount(domRule); + for (let i = 0; i < len; i++) { + let text = domUtils.getSelectorText(domRule, i); + selectors.push(text); + } + return selectors; +}; + +/** + * Given a node, check to see if it is a ::before or ::after element. + * If so, return the node that is accessible from within the document + * (the parent of the anonymous node), along with which pseudo element + * it was. Otherwise, return the node itself. + * + * @returns {Object} + * - {DOMNode} node The non-anonymous node + * - {string} pseudo One of ':before', ':after', or null. + */ +CssLogic.getBindingElementAndPseudo = function (node) { + let bindingElement = node; + let pseudo = null; + if (node.nodeName == "_moz_generated_content_before") { + bindingElement = node.parentNode; + pseudo = ":before"; + } else if (node.nodeName == "_moz_generated_content_after") { + bindingElement = node.parentNode; + pseudo = ":after"; + } + return { + bindingElement: bindingElement, + pseudo: pseudo + }; +}; + +/** + * Get the computed style on a node. Automatically handles reading + * computed styles on a ::before/::after element by reading on the + * parent node with the proper pseudo argument. + * + * @param {Node} + * @returns {CSSStyleDeclaration} + */ +CssLogic.getComputedStyle = function (node) { + if (!node || + Cu.isDeadWrapper(node) || + node.nodeType !== nodeConstants.ELEMENT_NODE || + !node.ownerDocument || + !node.ownerDocument.defaultView) { + return null; + } + + let {bindingElement, pseudo} = CssLogic.getBindingElementAndPseudo(node); + return node.ownerDocument.defaultView.getComputedStyle(bindingElement, + pseudo); +}; + +/** + * Get a source for a stylesheet, taking into account embedded stylesheets + * for which we need to use document.defaultView.location.href rather than + * sheet.href + * + * @param {CSSStyleSheet} sheet the DOM object for the style sheet. + * @return {string} the address of the stylesheet. + */ +CssLogic.href = function (sheet) { + let href = sheet.href; + if (!href) { + href = sheet.ownerNode.ownerDocument.location; + } + + return href; +}; + +/** + * Find the position of [element] in [nodeList]. + * @returns an index of the match, or -1 if there is no match + */ +function positionInNodeList(element, nodeList) { + for (let i = 0; i < nodeList.length; i++) { + if (element === nodeList[i]) { + return i; + } + } + return -1; +} + +/** + * Find a unique CSS selector for a given element + * @returns a string such that ele.ownerDocument.querySelector(reply) === ele + * and ele.ownerDocument.querySelectorAll(reply).length === 1 + */ +CssLogic.findCssSelector = function (ele) { + ele = getRootBindingParent(ele); + let document = ele.ownerDocument; + if (!document || !document.contains(ele)) { + throw new Error("findCssSelector received element not inside document"); + } + + // document.querySelectorAll("#id") returns multiple if elements share an ID + if (ele.id && + document.querySelectorAll("#" + CSS.escape(ele.id)).length === 1) { + return "#" + CSS.escape(ele.id); + } + + // Inherently unique by tag name + let tagName = ele.localName; + if (tagName === "html") { + return "html"; + } + if (tagName === "head") { + return "head"; + } + if (tagName === "body") { + return "body"; + } + + // We might be able to find a unique class name + let selector, index, matches; + if (ele.classList.length > 0) { + for (let i = 0; i < ele.classList.length; i++) { + // Is this className unique by itself? + selector = "." + CSS.escape(ele.classList.item(i)); + matches = document.querySelectorAll(selector); + if (matches.length === 1) { + return selector; + } + // Maybe it's unique with a tag name? + selector = tagName + selector; + matches = document.querySelectorAll(selector); + if (matches.length === 1) { + return selector; + } + // Maybe it's unique using a tag name and nth-child + index = positionInNodeList(ele, ele.parentNode.children) + 1; + selector = selector + ":nth-child(" + index + ")"; + matches = document.querySelectorAll(selector); + if (matches.length === 1) { + return selector; + } + } + } + + // Not unique enough yet. As long as it's not a child of the document, + // continue recursing up until it is unique enough. + if (ele.parentNode !== document) { + index = positionInNodeList(ele, ele.parentNode.children) + 1; + selector = CssLogic.findCssSelector(ele.parentNode) + " > " + + tagName + ":nth-child(" + index + ")"; + } + + return selector; +}; + +/** + * A safe way to access cached bits of information about a stylesheet. + * + * @constructor + * @param {CssLogic} cssLogic pointer to the CssLogic instance working with + * this CssSheet object. + * @param {CSSStyleSheet} domSheet reference to a DOM CSSStyleSheet object. + * @param {number} index tells the index/position of the stylesheet within the + * main document. + */ +function CssSheet(cssLogic, domSheet, index) { + this._cssLogic = cssLogic; + this.domSheet = domSheet; + this.index = this.contentSheet ? index : -100 * index; + + // Cache of the sheets href. Cached by the getter. + this._href = null; + // Short version of href for use in select boxes etc. Cached by getter. + this._shortSource = null; + + // null for uncached. + this._sheetAllowed = null; + + // Cached CssRules from the given stylesheet. + this._rules = {}; + + this._ruleCount = -1; +} + +CssSheet.prototype = { + _passId: null, + _contentSheet: null, + + /** + * Tells if the stylesheet is provided by the browser or not. + * + * @return {boolean} false if this is a browser-provided stylesheet, or true + * otherwise. + */ + get contentSheet() { + if (this._contentSheet === null) { + this._contentSheet = isContentStylesheet(this.domSheet); + } + return this._contentSheet; + }, + + /** + * Tells if the stylesheet is disabled or not. + * @return {boolean} true if this stylesheet is disabled, or false otherwise. + */ + get disabled() { + return this.domSheet.disabled; + }, + + /** + * Get a source for a stylesheet, using CssLogic.href + * + * @return {string} the address of the stylesheet. + */ + get href() { + if (this._href) { + return this._href; + } + + this._href = CssLogic.href(this.domSheet); + return this._href; + }, + + /** + * Create a shorthand version of the href of a stylesheet. + * + * @return {string} the shorthand source of the stylesheet. + */ + get shortSource() { + if (this._shortSource) { + return this._shortSource; + } + + this._shortSource = shortSource(this.domSheet); + return this._shortSource; + }, + + /** + * Tells if the sheet is allowed or not by the current CssLogic.sourceFilter. + * + * @return {boolean} true if the stylesheet is allowed by the sourceFilter, or + * false otherwise. + */ + get sheetAllowed() { + if (this._sheetAllowed !== null) { + return this._sheetAllowed; + } + + this._sheetAllowed = true; + + let filter = this._cssLogic.sourceFilter; + if (filter === FILTER.USER && !this.contentSheet) { + this._sheetAllowed = false; + } + if (filter !== FILTER.USER && filter !== FILTER.UA) { + this._sheetAllowed = (filter === this.href); + } + + return this._sheetAllowed; + }, + + /** + * Retrieve the number of rules in this stylesheet. + * + * @return {number} the number of nsIDOMCSSRule objects in this stylesheet. + */ + get ruleCount() { + return this._ruleCount > -1 ? + this._ruleCount : + this.domSheet.cssRules.length; + }, + + /** + * Retrieve a CssRule object for the given CSSStyleRule. The CssRule object is + * cached, such that subsequent retrievals return the same CssRule object for + * the same CSSStyleRule object. + * + * @param {CSSStyleRule} aDomRule the CSSStyleRule object for which you want a + * CssRule object. + * @return {CssRule} the cached CssRule object for the given CSSStyleRule + * object. + */ + getRule: function (domRule) { + let cacheId = domRule.type + domRule.selectorText; + + let rule = null; + let ruleFound = false; + + if (cacheId in this._rules) { + for (let i = 0, rulesLen = this._rules[cacheId].length; + i < rulesLen; + i++) { + rule = this._rules[cacheId][i]; + if (rule.domRule === domRule) { + ruleFound = true; + break; + } + } + } + + if (!ruleFound) { + if (!(cacheId in this._rules)) { + this._rules[cacheId] = []; + } + + rule = new CssRule(this, domRule); + this._rules[cacheId].push(rule); + } + + return rule; + }, + + toString: function () { + return "CssSheet[" + this.shortSource + "]"; + } +}; + +/** + * Information about a single CSSStyleRule. + * + * @param {CSSSheet|null} cssSheet the CssSheet object of the stylesheet that + * holds the CSSStyleRule. If the rule comes from element.style, set this + * argument to null. + * @param {CSSStyleRule|object} domRule the DOM CSSStyleRule for which you want + * to cache data. If the rule comes from element.style, then provide + * an object of the form: {style: element.style}. + * @param {Element} [element] If the rule comes from element.style, then this + * argument must point to the element. + * @constructor + */ +function CssRule(cssSheet, domRule, element) { + this._cssSheet = cssSheet; + this.domRule = domRule; + + let parentRule = domRule.parentRule; + if (parentRule && parentRule.type == CSSRule.MEDIA_RULE) { + this.mediaText = parentRule.media.mediaText; + } + + if (this._cssSheet) { + // parse domRule.selectorText on call to this.selectors + this._selectors = null; + this.line = domUtils.getRuleLine(this.domRule); + this.source = this._cssSheet.shortSource + ":" + this.line; + if (this.mediaText) { + this.source += " @media " + this.mediaText; + } + this.href = this._cssSheet.href; + this.contentRule = this._cssSheet.contentSheet; + } else if (element) { + this._selectors = [ new CssSelector(this, "@element.style", 0) ]; + this.line = -1; + this.source = l10n("rule.sourceElement"); + this.href = "#"; + this.contentRule = true; + this.sourceElement = element; + } +} + +CssRule.prototype = { + _passId: null, + + mediaText: "", + + get isMediaRule() { + return !!this.mediaText; + }, + + /** + * Check if the parent stylesheet is allowed by the CssLogic.sourceFilter. + * + * @return {boolean} true if the parent stylesheet is allowed by the current + * sourceFilter, or false otherwise. + */ + get sheetAllowed() { + return this._cssSheet ? this._cssSheet.sheetAllowed : true; + }, + + /** + * Retrieve the parent stylesheet index/position in the viewed document. + * + * @return {number} the parent stylesheet index/position in the viewed + * document. + */ + get sheetIndex() { + return this._cssSheet ? this._cssSheet.index : 0; + }, + + /** + * Retrieve the style property value from the current CSSStyleRule. + * + * @param {string} property the CSS property name for which you want the + * value. + * @return {string} the property value. + */ + getPropertyValue: function (property) { + return this.domRule.style.getPropertyValue(property); + }, + + /** + * Retrieve the style property priority from the current CSSStyleRule. + * + * @param {string} property the CSS property name for which you want the + * priority. + * @return {string} the property priority. + */ + getPropertyPriority: function (property) { + return this.domRule.style.getPropertyPriority(property); + }, + + /** + * Retrieve the list of CssSelector objects for each of the parsed selectors + * of the current CSSStyleRule. + * + * @return {array} the array hold the CssSelector objects. + */ + get selectors() { + if (this._selectors) { + return this._selectors; + } + + // Parse the CSSStyleRule.selectorText string. + this._selectors = []; + + if (!this.domRule.selectorText) { + return this._selectors; + } + + let selectors = CssLogic.getSelectors(this.domRule); + + for (let i = 0, len = selectors.length; i < len; i++) { + this._selectors.push(new CssSelector(this, selectors[i], i)); + } + + return this._selectors; + }, + + toString: function () { + return "[CssRule " + this.domRule.selectorText + "]"; + }, +}; + +/** + * The CSS selector class allows us to document the ranking of various CSS + * selectors. + * + * @constructor + * @param {CssRule} cssRule the CssRule instance from where the selector comes. + * @param {string} selector The selector that we wish to investigate. + * @param {Number} index The index of the selector within it's rule. + */ +function CssSelector(cssRule, selector, index) { + this.cssRule = cssRule; + this.text = selector; + this.elementStyle = this.text == "@element.style"; + this._specificity = null; + this.selectorIndex = index; +} + +exports.CssSelector = CssSelector; + +CssSelector.prototype = { + _matchId: null, + + /** + * Retrieve the CssSelector source, which is the source of the CssSheet owning + * the selector. + * + * @return {string} the selector source. + */ + get source() { + return this.cssRule.source; + }, + + /** + * Retrieve the CssSelector source element, which is the source of the CssRule + * owning the selector. This is only available when the CssSelector comes from + * an element.style. + * + * @return {string} the source element selector. + */ + get sourceElement() { + return this.cssRule.sourceElement; + }, + + /** + * Retrieve the address of the CssSelector. This points to the address of the + * CssSheet owning this selector. + * + * @return {string} the address of the CssSelector. + */ + get href() { + return this.cssRule.href; + }, + + /** + * Check if the selector comes from a browser-provided stylesheet. + * + * @return {boolean} true if the selector comes from a content-provided + * stylesheet, or false otherwise. + */ + get contentRule() { + return this.cssRule.contentRule; + }, + + /** + * Check if the parent stylesheet is allowed by the CssLogic.sourceFilter. + * + * @return {boolean} true if the parent stylesheet is allowed by the current + * sourceFilter, or false otherwise. + */ + get sheetAllowed() { + return this.cssRule.sheetAllowed; + }, + + /** + * Retrieve the parent stylesheet index/position in the viewed document. + * + * @return {number} the parent stylesheet index/position in the viewed + * document. + */ + get sheetIndex() { + return this.cssRule.sheetIndex; + }, + + /** + * Retrieve the line of the parent CSSStyleRule in the parent CSSStyleSheet. + * + * @return {number} the line of the parent CSSStyleRule in the parent + * stylesheet. + */ + get ruleLine() { + return this.cssRule.line; + }, + + /** + * Retrieve specificity information for the current selector. + * + * @see http://www.w3.org/TR/css3-selectors/#specificity + * @see http://www.w3.org/TR/CSS2/selector.html + * + * @return {Number} The selector's specificity. + */ + get specificity() { + if (this.elementStyle) { + // We can't ask specificity from DOMUtils as element styles don't provide + // CSSStyleRule interface DOMUtils expect. However, specificity of element + // style is constant, 1,0,0,0 or 0x01000000, just return the constant + // directly. @see http://www.w3.org/TR/CSS2/cascade.html#specificity + return 0x01000000; + } + + if (this._specificity) { + return this._specificity; + } + + this._specificity = domUtils.getSpecificity(this.cssRule.domRule, + this.selectorIndex); + + return this._specificity; + }, + + toString: function () { + return this.text; + }, +}; + +/** + * A cache of information about the matched rules, selectors and values attached + * to a CSS property, for the highlighted element. + * + * The heart of the CssPropertyInfo object is the _findMatchedSelectors() + * method. This are invoked when the PropertyView tries to access the + * .matchedSelectors array. + * Results are cached, for later reuse. + * + * @param {CssLogic} cssLogic Reference to the parent CssLogic instance + * @param {string} property The CSS property we are gathering information for + * @param {function} isInherited A function that determines if the CSS property + * is inherited. + * @constructor + */ +function CssPropertyInfo(cssLogic, property, isInherited) { + this._cssLogic = cssLogic; + this.property = property; + this._value = ""; + this._isInherited = isInherited; + + // An array holding CssSelectorInfo objects for each of the matched selectors + // that are inside a CSS rule. Only rules that hold the this.property are + // counted. This includes rules that come from filtered stylesheets (those + // that have sheetAllowed = false). + this._matchedSelectors = null; +} + +CssPropertyInfo.prototype = { + /** + * Retrieve the computed style value for the current property, for the + * highlighted element. + * + * @return {string} the computed style value for the current property, for the + * highlighted element. + */ + get value() { + if (!this._value && this._cssLogic.computedStyle) { + try { + this._value = + this._cssLogic.computedStyle.getPropertyValue(this.property); + } catch (ex) { + console.log("Error reading computed style for " + this.property); + console.log(ex); + } + } + return this._value; + }, + + /** + * Retrieve the array holding CssSelectorInfo objects for each of the matched + * selectors, from each of the matched rules. Only selectors coming from + * allowed stylesheets are included in the array. + * + * @return {array} the list of CssSelectorInfo objects of selectors that match + * the highlighted element and its parents. + */ + get matchedSelectors() { + if (!this._matchedSelectors) { + this._findMatchedSelectors(); + } else if (this.needRefilter) { + this._refilterSelectors(); + } + + return this._matchedSelectors; + }, + + /** + * Find the selectors that match the highlighted element and its parents. + * Uses CssLogic.processMatchedSelectors() to find the matched selectors, + * passing in a reference to CssPropertyInfo._processMatchedSelector() to + * create CssSelectorInfo objects, which we then sort + * @private + */ + _findMatchedSelectors: function () { + this._matchedSelectors = []; + this.needRefilter = false; + + this._cssLogic.processMatchedSelectors(this._processMatchedSelector, this); + + // Sort the selectors by how well they match the given element. + this._matchedSelectors.sort(function (selectorInfo1, selectorInfo2) { + if (selectorInfo1.status > selectorInfo2.status) { + return -1; + } else if (selectorInfo2.status > selectorInfo1.status) { + return 1; + } + return selectorInfo1.compareTo(selectorInfo2); + }); + + // Now we know which of the matches is best, we can mark it BEST_MATCH. + if (this._matchedSelectors.length > 0 && + this._matchedSelectors[0].status > STATUS.UNMATCHED) { + this._matchedSelectors[0].status = STATUS.BEST; + } + }, + + /** + * Process a matched CssSelector object. + * + * @private + * @param {CssSelector} selector the matched CssSelector object. + * @param {STATUS} status the CssSelector match status. + */ + _processMatchedSelector: function (selector, status) { + let cssRule = selector.cssRule; + let value = cssRule.getPropertyValue(this.property); + if (value && + (status == STATUS.MATCHED || + (status == STATUS.PARENT_MATCH && + this._isInherited(this.property)))) { + let selectorInfo = new CssSelectorInfo(selector, this.property, value, + status); + this._matchedSelectors.push(selectorInfo); + } + }, + + /** + * Refilter the matched selectors array when the CssLogic.sourceFilter + * changes. This allows for quick filter changes. + * @private + */ + _refilterSelectors: function () { + let passId = ++this._cssLogic._passId; + + let iterator = function (selectorInfo) { + let cssRule = selectorInfo.selector.cssRule; + if (cssRule._passId != passId) { + cssRule._passId = passId; + } + }; + + if (this._matchedSelectors) { + this._matchedSelectors.forEach(iterator); + } + + this.needRefilter = false; + }, + + toString: function () { + return "CssPropertyInfo[" + this.property + "]"; + }, +}; + +/** + * A class that holds information about a given CssSelector object. + * + * Instances of this class are given to CssHtmlTree in the array of matched + * selectors. Each such object represents a displayable row in the PropertyView + * objects. The information given by this object blends data coming from the + * CssSheet, CssRule and from the CssSelector that own this object. + * + * @param {CssSelector} selector The CssSelector object for which to + * present information. + * @param {string} property The property for which information should + * be retrieved. + * @param {string} value The property value from the CssRule that owns + * the selector. + * @param {STATUS} status The selector match status. + * @constructor + */ +function CssSelectorInfo(selector, property, value, status) { + this.selector = selector; + this.property = property; + this.status = status; + this.value = value; + let priority = this.selector.cssRule.getPropertyPriority(this.property); + this.important = (priority === "important"); +} + +CssSelectorInfo.prototype = { + /** + * Retrieve the CssSelector source, which is the source of the CssSheet owning + * the selector. + * + * @return {string} the selector source. + */ + get source() { + return this.selector.source; + }, + + /** + * Retrieve the CssSelector source element, which is the source of the CssRule + * owning the selector. This is only available when the CssSelector comes from + * an element.style. + * + * @return {string} the source element selector. + */ + get sourceElement() { + return this.selector.sourceElement; + }, + + /** + * Retrieve the address of the CssSelector. This points to the address of the + * CssSheet owning this selector. + * + * @return {string} the address of the CssSelector. + */ + get href() { + return this.selector.href; + }, + + /** + * Check if the CssSelector comes from element.style or not. + * + * @return {boolean} true if the CssSelector comes from element.style, or + * false otherwise. + */ + get elementStyle() { + return this.selector.elementStyle; + }, + + /** + * Retrieve specificity information for the current selector. + * + * @return {object} an object holding specificity information for the current + * selector. + */ + get specificity() { + return this.selector.specificity; + }, + + /** + * Retrieve the parent stylesheet index/position in the viewed document. + * + * @return {number} the parent stylesheet index/position in the viewed + * document. + */ + get sheetIndex() { + return this.selector.sheetIndex; + }, + + /** + * Check if the parent stylesheet is allowed by the CssLogic.sourceFilter. + * + * @return {boolean} true if the parent stylesheet is allowed by the current + * sourceFilter, or false otherwise. + */ + get sheetAllowed() { + return this.selector.sheetAllowed; + }, + + /** + * Retrieve the line of the parent CSSStyleRule in the parent CSSStyleSheet. + * + * @return {number} the line of the parent CSSStyleRule in the parent + * stylesheet. + */ + get ruleLine() { + return this.selector.ruleLine; + }, + + /** + * Check if the selector comes from a browser-provided stylesheet. + * + * @return {boolean} true if the selector comes from a browser-provided + * stylesheet, or false otherwise. + */ + get contentRule() { + return this.selector.contentRule; + }, + + /** + * Compare the current CssSelectorInfo instance to another instance, based on + * specificity information. + * + * @param {CssSelectorInfo} that The instance to compare ourselves against. + * @return number -1, 0, 1 depending on how that compares with this. + */ + compareTo: function (that) { + if (!this.contentRule && that.contentRule) { + return 1; + } + if (this.contentRule && !that.contentRule) { + return -1; + } + + if (this.elementStyle && !that.elementStyle) { + if (!this.important && that.important) { + return 1; + } + return -1; + } + + if (!this.elementStyle && that.elementStyle) { + if (this.important && !that.important) { + return -1; + } + return 1; + } + + if (this.important && !that.important) { + return -1; + } + if (that.important && !this.important) { + return 1; + } + + if (this.specificity > that.specificity) { + return -1; + } + if (that.specificity > this.specificity) { + return 1; + } + + if (this.sheetIndex > that.sheetIndex) { + return -1; + } + if (that.sheetIndex > this.sheetIndex) { + return 1; + } + + if (this.ruleLine > that.ruleLine) { + return -1; + } + if (that.ruleLine > this.ruleLine) { + return 1; + } + + return 0; + }, + + toString: function () { + return this.selector + " -> " + this.value; + }, +}; + +DevToolsUtils.defineLazyGetter(this, "domUtils", function () { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); +}); diff --git a/devtools/server/docs/actor-e10s-handling.md b/devtools/server/docs/actor-e10s-handling.md new file mode 100644 index 000000000..ad6919ffd --- /dev/null +++ b/devtools/server/docs/actor-e10s-handling.md @@ -0,0 +1,106 @@ +# How to handle E10S in actors + +In multi-process environments, most devtools actors are created and initialized in the child content process, to be able to access the resources they are exposing to the toolbox. But sometimes, these actors need to access things in the parent process too. Here's why and how. + +## Use case and examples + +Some actors need to exchange messages between the parent and the child process (typically when some components aren't available in the child process). + +E.g. the **director-manager** needs to ask the list of installed **director scripts** from +the **director-registry** running in the parent process. + +To that end, there's a parent/child setup mechanism at `DebuggerServer` level that can be used. + +When the actor is loaded for the first time in the `DebuggerServer` running in the child process, it may decide to run a setup procedure to load a module in the parent process with which to communicate. + +E.g. in the **director-registry**: + +``` + const {DebuggerServer} = require("devtools/server/main"); + + // Setup the child<->parent communication only if the actor module + // is running in a child process. + if (DebuggerServer.isInChildProcess) { + setupChildProcess(); + } + + function setupChildProcess() { + // `setupInParent` is defined on DebuggerServerConnection, + // your actor receive a reference to one instance in its constructor. + conn.setupInParent({ + module: "devtools/server/actors/director-registry", + setupParent: "setupParentProcess" + }); + // ... + } +``` + +The `setupChildProcess` helper defined and used in the previous example uses the `DebuggerServerConnection.setupInParent` to run a given setup function in the parent process Debugger Server, e.g. in the **director-registry** module. + +With this, the `DebuggerServer` running in the parent process will require the requested module (**director-registry**) and call its `setupParentProcess` function (which should be exported on the module). + +The `setupParentProcess` function will receive a parameter that contains a reference to the **MessageManager** and a prefix that should be used to send/receive messages between the child and parent processes. + +See below an example implementation of a `setupParent` function in the parent process: + +``` +exports.setupParentProcess = function setupParentProcess({ mm, prefix }) { + // Start listening for messages from the actor in the child process. + setMessageManager(mm); + + function handleChildRequest(msg) { + switch (msg.json.method) { + case "get": + return doGetInParentProcess(msg.json.args[0]); + break; + case "list": + return doListInParentProcess(); + break; + default: + console.error("Unknown method name", msg.json.method); + throw new Error("Unknown method name"); + } + } + + function setMessageManager(newMM) { + if (mm) { + // Remove listener from old message manager + mm.removeMessageListener("debug:some-message-name", handleChildRequest); + } + // Switch to the new message manager for future use + // Note: Make sure that any other functions also use the new reference. + mm = newMM; + if (mm) { + // Add listener to new message manager + mm.addMessageListener("debug:some-message-name", handleChildRequest); + } + } + + return { + onBrowserSwap: setMessageManager, + onDisconnected: () => setMessageManager(null), + }; +}; +``` + +The server will call the `onDisconnected` method returned by the parent process setup flow to give the actor modules the chance to cleanup their handlers registered on the disconnected message manager. + +The server will call the `onBrowserSwap` method returned by the parent process setup flow to notify actor modules when the message manager for the target frame has changed. The parent process code should remove any message listeners from the previous message manager and add them to the new one. + +## Summary of the setup flow + +In the child process: + +* The `DebuggerServer` loads an actor module, +* the actor module checks `DebuggerServer.isInChildProcess` to know whether it runs in a child process or not, +* the actor module then uses the `DebuggerServerConnection.setupInParent` helper to start setting up a parent-process counterpart, +* the `DebuggerServerConnection.setupInParent` helper asks the parent process to run the required module's setup function, +* the actor module uses the `DebuggerServerConnection.parentMessageManager.sendSyncMessage` and `DebuggerServerConnection.parentMessageManager.addMessageListener` helpers to send or listen to message. + +In the parent process: + +* The DebuggerServer receives the `DebuggerServerConnection.setupInParent` request, +* tries to load the required module, +* tries to call the `module[setupParent]` function with the frame message manager and the prefix as parameters `{ mm, prefix }`, +* the `setupParent` function then uses the mm to subscribe the message manager events, +* the `setupParent` function returns an object with `onDisconnected` and `onBrowserSwap` methods which the server can use to notify the module of various lifecycle events diff --git a/devtools/server/docs/actor-hierarchy.md b/devtools/server/docs/actor-hierarchy.md new file mode 100644 index 000000000..28ddd97c4 --- /dev/null +++ b/devtools/server/docs/actor-hierarchy.md @@ -0,0 +1,129 @@ +# How actors are organized + +To start with, actors are living within /devtools/server/actors/ folder. +They are organized in a hierarchy for easier lifecycle/memory management: +once a parent is removed from the pool, its children are removed as well. +(See actor-registration.md for more information about how to implement one) + +The overall hierarchy of actors looks like this: + + RootActor: First one, automatically instantiated when we start connecting. + | Mostly meant to instantiate new actors. + | + |--> Global-scoped actors: + | Actors exposing features related to the main process, + | that are not specific to any particular context (document, tab, app, + | add-on, or worker). + | A good example is the preference actor. + | + \--> "TabActor" (or alike): + | Actors meant to designate one context (document, tab, app, + | add-on, or worker) and track its lifetime. Generally, there is + | one of these for each thing you can point a toolbox at. + | + \--> Tab-scoped actors: + Actors exposing one particular feature set, this time, + specific to a given context (document, tab, app, add-on, or + worker). Examples include the console and inspector actors. + These actors may extend this hierarchy by having their + own children, like LongStringActor, WalkerActor, etc. + +## RootActor + +The root actor is special. It is automatically created when a client connects. +It has a special `actorID` which is unique and is "root". +All other actors have an `actorID` which is computed dynamically, +so that you need to ask an existing actor to create an Actor +and returns its `actorID`. That's the main role of RootActor. + + RootActor (root.js) + | + |-- BrowserTabActor (webbrowser.js) + | Targets tabs living in the parent process when e10s (multiprocess) + | is turned off for this tab. + | Returned by "listTabs" or "getTab" requests. + | + |-- RemoteBrowserActor (webbrowser.js) + | Targets tabs living in the child process when e10s (multiprocess) is + | turned on for this tab. Note that this is just a proxy for ContentActor, + | that lives in the child process. + | Returned by "listTabs" or "getTab" requests. + | | + | \-> ContentActor (childtab.js) + | Targets tabs living out-of-process (e10s) or apps (on firefox OS). + | Returned by "connect" on RemoteBrowserActor (for tabs) or + | "getAppActor" on the Webapps actor (for apps). + | + |-- WorkerActor (worker.js) + | Targets a worker (applies to various kinds like web worker, service + | worker, etc.). + | Returned by "listWorkers" request to the root actor to get all workers. + | Returned by "listWorkers" request to a BrowserTabActor to get workers for + | a specific tab. + | Returned by "listWorkers" request to a ChildProcessActor to get workers + | for the chrome of the child process. + | + |-- ChromeActor (chrome.js) + | Targets all resources in the parent process of firefox + | (chrome documents, JSM, JS XPCOM, etc.). + | Returned by "getProcess" request without any argument. + | + |-- ChildProcessActor (child-process.js) + | Targets the chrome of the child process (e10s). + | Returned by "getProcess" request with a id argument, + | matching the targeted process. + | + \-- BrowserAddonActor (addon.js) + Targets the javascript of add-ons. + Returned by "listAddons" request. + +## "TabActor" + +Those are the actors exposed by the root actors which are meant to track the +lifetime of a given context: tab, app, process, add-on, or worker. It also +allows to fetch the tab-scoped actors connected to this context. Actors like +console, inspector, thread (for debugger), styleinspector, etc. Most of them +inherit from TabActor (defined in webbrowser.js) which is document centric. It +automatically tracks the lifetime of the targeted document, but it also tracks +its iframes and allows switching the context to one of its iframes. For +historical reasons, these actors also handle creating the ThreadActor, used to +manage breakpoints in the debugger. All the other tab-scoped actors are created +when we access the TabActor's grip. We return the tab-scoped actors `actorID` in +it. Actors inheriting from TabActor expose `attach`/`detach` requests, that +allows to start/stop the ThreadActor. + +The tab-scoped actors expect to find the following properties on the "TabActor": + - threadActor: + ThreadActor instance for the given context, + only defined once `attach` request is called, or on construction. + - isRootActor: (historical name) + Always false, except on ChromeActor. + Despite the attribute name, it is being used to accept all resources + (like chrome one) instead of limiting only to content resources. + - makeDebugger: + Helper function used to create Debugger object for the targeted context. + (See actors/utils/make-debugger.js for more info) + +In addition to this, the actors inheriting from TabActor, expose many other +attributes and events: + - window: + Reference to the window global object currently targeted. + It can change over time if we switch context to an iframe, so it + shouldn't be stored in a variable, but always retrieved from the actor. + - windows: + List of all document globals including the main window object and all iframes. + - docShell: + DocShell reference for the targeted context. + - docShells: + List of all docshells for the targeted document and all its iframes. + - chromeEventHandler: + The chrome event handler for the current context. Allows to listen to events + that can be missing/cancelled on this document itself. + +See TabActor documentation for events definition. + +## Tab-scoped actors + +Each of these actors focuses on providing one particular feature set, specific +to one context, that can be a web page, an app, a top level firefox window, a +process, an add-on, or a worker. diff --git a/devtools/server/docs/actor-registration.md b/devtools/server/docs/actor-registration.md new file mode 100644 index 000000000..8d79d9372 --- /dev/null +++ b/devtools/server/docs/actor-registration.md @@ -0,0 +1,41 @@ +# How to register an actor + +## Tab actors vs. global actors + +Tab actors are the most common types of actors. That's the type of actors you will most probably be adding. + +Tab actors target a document, this could be a tab in Firefox, an app on B2G or a remote document in Firefox for Android/Safari/Chrome for Android (via Valence). + +Global actors however are for the rest, for things not related to any particular document but instead for things global to the whole Firefox/B2G/Chrome/Safari intance the toolbox is connected to (e.g. the preference actor). + +## The DebuggerServer.registerModule function + +To register a tab actor: + +``` +DebuggerServer.registerModule("devtools/server/actors/webconsole", { + prefix: "console", + constructor: "WebConsoleActor", + type: { tab: true } +}); +``` + +To register a global actor: + +``` +DebuggerServer.registerModule("devtools/server/actors/addons", { + prefix: "addons", + constructor: "AddonsActor", + type: { global: true } +}); +``` + +If you are adding a new built-in devtools actor, you should be registering it using `DebuggerServer.registerModule` in `addBrowserActors` or `addTabActors` in `/devtools/server/main.js`. + +If you are adding a new actor from an add-on, you should call `DebuggerServer.registerModule` directly from your add-on code. + +## A note about lazy registration + +The `DebuggerServer` loads and creates all of the actors lazily to keep the initial memory usage down (which is extremely important on lower end devices). + +It becomes especially important when debugging apps on b2g or pages with e10s when there are more than one process, because that's when we need to spawn a `DebuggerServer` per process (it may not be immediately obvious that the server in the main process is mostly only here for piping messages to the actors in the child process). diff --git a/devtools/server/docs/protocol.js.md b/devtools/server/docs/protocol.js.md new file mode 100644 index 000000000..e033bf142 --- /dev/null +++ b/devtools/server/docs/protocol.js.md @@ -0,0 +1,651 @@ +Writing an Actor +================ + +A Simple Hello World +-------------------- + +Here's a simple Hello World actor. It is a global actor (not associated with a given browser tab). +It has two parts: a spec and an implementation. The spec would go somewhere like +`devtools/shared/specs/hello-world.js` and would look like: + + const {Arg, RetVal, generateActorSpec} = require("devtools/shared/protocol"); + + const helloWorldSpec = generateActorSpec({ + typeName: "helloWorld", // I'll explain types later, I promise. + + methods: { + sayHello: { + // The request packet template. There are no arguments, so + // it is empty. The framework will add the "type" and "to" + // request properties. + request: {}, + + // The response packet template. The return value of the function + // will be plugged in where the RetVal() appears in the template. + response: { + greeting: RetVal("string") // "string" is the return value type. + } + }, + }, + }); + + // Expose the spec so it can be imported by the implementation. + exports.helloWorldSpec = helloWorldSpec; + +The actor implementation would go somewhere like +`devtools/server/actors/hello-world.js` and would look like: + + const protocol = require("devtools/shared/protocol"); + const {helloWorldSpec} = require("devtools/shared/specs/hello-world"); + + const HelloActor = protocol.ActorClassWithSpec(helloWorldSpec, { + initialize: function (conn) { + protocol.Actor.prototype.initialize.call(this, conn); // This is the worst part of heritage. + }, + + sayHello: function () { + return "hello"; + }, + }); + + // You also need to export the actor class in your module for discovery. + exports.HelloActor = HelloActor; + +To activate your actor, register it in the `addBrowserActors` method in `server/main.js`. +The registration code would look something like this: + + this.registerModule("devtools/server/actors/hello-world", { + prefix: "hello", + constructor: "HelloActor", + type: { global: true } + }); + +Your spec allows the actor to support a `sayHello` request. +A request/reply will look like this: + + -> { to: <actorID>, type: "sayHello" } + <- { from: <actorID>, greeting: "hello" } + +Now we can create a client side object. We call these *front* objects and +they typically go in `devtools/shared/fronts/`. + +Here's the front for the HelloActor: + + const HelloFront = protocol.FrontClassWithSpec(helloWorldSpec, { + initialize: function (client, form) { + protocol.Front.prototype.initialize.call(this, client, form); + // This call may not be required but it's a good idea. It will + // guarantee that your instance is managed in the pool. + this.manage(this); + } + }); + +Note that there is no `sayHello` method. The FrontClass will generate a method on the Front object that matches the method declaration in the Actor class. + +The generated methods will return a Promise. That promise will resolve to the RetVal of the actor method. + +So if we have a reference to a HelloFront object, we can issue a `sayHello` request: + + hello.sayHello().then(greeting => { + console.log(greeting); + }); + +How do you get an initial reference to the front? That's a bit tricky, but basically there are two ways: + +* Manually +* Magically + +Manually - If you're using a DebuggerClient instance, you can discover the actorID manually and create a Front for it: + + let hello = HelloFront(this.client, { actor: <hello actorID> }); + +Magically - Once you have an initial reference to a protocol.js object, it can return other protocol.js objects and fronts will automatically be created. + +Arguments +--------- + +`sayHello` has no arguments, so let's add a method that does take arguments. +Here's an adjustment to the spec: + + methods: { + echo: { + request: { echo: Arg(0, "string") }, + response: { echoed: RetVal("string") } + } + } + +Here's an adjustment to the implementation: + + echo: function (str) { + return str + "... " + str + "..."; + } + +This tells the library to place the 0th argument, which should be a string, in the `echo` property of the request packet. + + +This will generate a request handler whose request and response packets look like this: + + { to: <actorID>, type: "echo", echo: <str> } + { from: <actorID>, echoed: <str> } + +The client usage should be predictable: + + hello.echo("hello").then(str => { assert(str === "hello... hello...") }) + +The library tries hard to make using fronts feel like natural javascript (or as natural as you believe promises are, I guess). When building the response it will put the return value of the function where RetVal() is specified in the response template, and on the client side it will use the value in that position when resolving the promise. + +Returning JSON +-------------- + +Maybe your response is an object. Here's an example of a spec: + + methods: { + addOneTwice: { + request: { a: Arg(0, "number"), b: Arg(1, "number") }, + response: { ret: RetVal("json") } + } + } + +Here's an example implementation: + + addOneTwice: function (a, b) { + return { a: a + 1, b: b + 1 }; + } + +This will generate a response packet that looks like: + + { from: <actorID>, ret: { a: <number>, b: <number> } } + +That's probably unnecessary nesting (if you're sure you won't be returning an object with 'from' as a key!), so you can just replace `response` with: + + response: RetVal("json") + +and now your packet will look like: + + { from: <actorID>, a: <number>, b: <number> } + +Types and Marshalling +--------------------- + +Things have been pretty simple up to this point - all the arguments we've passed in have been javascript primitives. But for some types (most importantly Actor types, which I'll get to eventually), we can't just copy them into a JSON packet and expect it to work, we need to marshal things ourselves. + +Again, the protocol lib tries hard to provide a natural API to actors and clients, and sometime that natural API might involve object APIs. I'm going to use a wickedly contrived example, bear with me. Let's say I have a small object that contains a number and has a few methods associated with it: + + let Incrementor = function (i) { + this.value = value; + } + Incrementor.prototype = { + increment: function () { this.value++ }, + decrement: function () { this.value-- } + }; + + +and I want to return it from a backend function: + + // spec: + methods: { + getIncrementor: { + request: { number: Arg(0, "number") }, + response: { value: RetVal("incrementor") } // We'll define "incrementor" below. + } + } + + // implementation: + getIncrementor: function (i) { + return new Incrementor(i) + } + +I want that response to look like `{ from: <actorID>, value: <number> }`, but the client side needs to know to return an Incrementor, not a primitive number. So let's tell the protocol lib about Incrementors: + + protocol.types.addType("incrementor", { + // When writing to a protocol packet, just send the value + write: (v) => v.value, + + // When reading from a protocol packet, wrap with an Incrementor + // object. + read: (v) => new Incrementor(v) + }); + +And now our client can use the API as expected: + + front.getIncrementor(5).then(incrementor => { + incrementor.increment(); + assert(incrementor.value === 6); + }); + +You can do the same thing with arguments: + + // spec: + methods: { + passIncrementor: { + request: { Arg(0, "incrementor") }, + } + } + + // implementation: + passIncrementor: function (inc) { + w.increment(); + assert(incrementor.value === 6); + } + + front.passIncrementor(new Incrementor(5)); + +The library provides primitiive `boolean`, `number`, `string`, and `json` types. + +Moving right along, let's say you want to pass/return an array of Incrementors. You can just prepend `array:` to the type name: + + // spec: + methods: { + incrementAll: { + request: { incrementors: Arg(0, "array:incrementor") }, + response: { incrementors: RetVal("array:incrementor") } + } + } + + // implementation: + incrementAll: function (incrementors) { + incrementors.forEach(incrementor => { + incrementor.increment(); + } + return incrementors; + } + +You can use an iterator in place of an array as an argument or return value, and the library will handle the conversion automatically. + +Or maybe you want to return a dictionary where one item is a incrementor. To do this you need to tell the type system which members of the dictionary need custom marshallers: + + protocol.types.addDictType("contrivedObject", { + incrementor: "incrementor", + incrementorArray: "array:incrementor" + }); + + // spec: + methods: { + reallyContrivedExample: { + response: RetVal("contrivedObject") + } + } + + // implementations: + reallyContrivedExample: function () { + return { + /* a and b are primitives and so don't need to be called out specifically in addDictType */ + a: "hello", b: "world", + incrementor: new Incrementor(1), + incrementorArray: [new Incrementor(2), new Incrementor(3)] + } + } + + front.reallyContrivedExample().then(obj => { + assert(obj.a == "hello"); + assert(obj.b == "world"); + assert(incrementor.i == 1); + assert(incrementorArray[0].i == 2); + assert(incrementorArray[1].i == 3); + }); + +Nullables +--------- + +If an argument, return value, or dict property can be null/undefined, you can prepend `nullable:` to the type name: + + "nullable:incrementor", // Can be null/undefined or an incrementor + "array:nullable:incrementor", // An array of incrementors that can have holes. + "nullable:array:incrementor" // Either null/undefined or an array of incrementors without holes. + + +Actors +------ + +Probably the most common objects that need custom martialing are actors themselves. These are more interesting than the Incrementor object, but by default they're somewhat easy to work with. Let's add a ChildActor implementation that will be returned by the HelloActor (which is rapidly becoming the OverwhelminglyComplexActor): + + // spec: + const childActorSpec = generateActorSpec({ + actorType: "childActor", + methods: { + getGreeting: { + response: { greeting: RetVal("string") }, + } + } + }); + + // implementation: + const ChildActor = protocol.ActorClassWithSpec(childActorSpec, { + initialize: function (conn, id) { + protocol.Actor.prototype.initialize.call(this, conn); + this.greeting = "hello from " + id; + }, + getGreeting: function () { + return this.greeting; + }, + }); + + exports.ChildActor = ChildActor; + + const ChildFront = protocol.FrontClassWithSpec(childActorSpec, { + initialize: function (client, form) { + protocol.Front.prototype.initialize.call(this, client, form); + }, + }); + +The library will register a marshaller for the actor type itself, using typeName as its tag. + +So we can now add the following code to HelloActor: + + // spec: + methods: { + getChild: { + request: { id: Arg(0, "string") }, + response: { child: RetVal("childActor") } + } + } + + // implementation: + getChild: function (id) { + return ChildActor(this.conn, id); + } + + front.getChild("child1").then(childFront => { + return childFront.getGreeting(); + }).then(greeting => { + assert(id === "hello from child1"); + }); + +The conversation will look like this: + + { to: <actorID>, type: "getChild", id: "child1" } + { from: <actorID>, child: { actor: <childActorID> }} + { to: <childActorID>, type: "getGreeting" } + { from: <childActorID>, greeting: "hello from child1" } + +But the ID is the only interesting part of this made-up example. You're never going to want a reference to a ChildActor without checking its ID. Making an extra request just to get that id is wasteful. You really want the first response to look like `{ from: <actorID>, child: { actor: <childActorID>, greeting: "hello from child1" } }` + +You can customize the marshalling of an actor by providing a `form` method in the `ChildActor` class: + + form: function () { + return { + actor: this.actorID, + greeting: this.greeting + } + }, + +And you can demarshal in the `ChildFront` class by implementing a matching `form` method: + + form: function (form) { + this.actorID = form.actor; + this.greeting = form.greeting; + } + +Now you can use the id immediately: + + front.getChild("child1").then(child => { assert(child.greeting === "child1) }); + +You may come across a situation where you want to customize the output of a `form` method depending on the operation being performed. For example, imagine that ChildActor is a bit more complex, with a, b, c, and d members: + + ChildActor: + form: function () { + return { + actor: this.actorID, + greeting: this.greeting, + a: this.a, + b: this.b, + c: this.c, + d: this.d + } + } + ChildFront: + form: function (form) { + this.actorID = form.actorID; + this.id = form.id; + this.a = form.a; + this.b = form.b; + this.c = form.c; + this.d = form.d; + } + +And imagine you want to change 'c' and return the object: + + // Oops! If a type is going to return references to itself or any other + // type that isn't fully registered yet, you need to predeclare the type. + types.addActorType("childActor"); + + ... + + // spec: + methods: { + changeC: { + request: { newC: Arg(0) }, + response: { self: RetVal("childActor") } + } + } + + // implementation: + changeC: function (newC) { + c = newC; + return this; + } + + ... + + childFront.changeC('hello').then(ret => { assert(ret === childFront); assert(childFront.c === "hello") }); + +Now our response will look like: + + { from: <childActorID>, self: { actor: <childActorID>, greeting: <id>, a: <a>, b: <b>, c: "hello", d: <d> } + +But that's wasteful. Only c changed. So we can provide a *detail* to the type using `#`: + + response: { self: RetVal("childActor#changec") } + +and update our form methods to make use of that data: + + // In ChildActor: + form: function (detail) { + if (detail === "changec") { + return { actor: this.actorID, c: this.c } + } + // ... the rest of the form method stays the same. + } + + // In ChildFront: + form: function (form, detail) { + if (detail === "changec") { + this.actorID = form.actor; + this.c = form.c; + return; + } + // ... the rest of the form method stays the same. + } + +Now the packet looks like a much more reasonable `{ from: <childActorID>, self: { actor: <childActorID>, c: "hello" } }` + +Lifetimes +--------- + +No, I don't want to talk about lifetimes quite yet. + +Events +------ + +Your actor has great news! + +Actors are subclasses of jetpack `EventTarget`, so you can just emit events. +Here's how you'd set it up in a spec: + + events: { + "good-news": { + type: "goodNews", // event target naming and packet naming are at odds, and we want both to be natural! + news: Arg(0) + } + } + + methods: { + giveGoodNews: { + request: { news: Arg(0) } + } + } + +Here's how the implementation would look: + + const event = require("sdk/event/core"); + + // In your protocol.ActorClassWithSpec definition: + giveGoodNews: function (news) { + event.emit(this, "good-news", news); + } + +Now you can listen to events on a front: + + front.on("good-news", news => { + console.log(`Got some good news: ${news}\n`); + }); + front.giveGoodNews().then(() => { console.log("request returned.") }); + +You might want to update your front's state when an event is fired, before emitting it against the front. You can use `preEvent` in the front definition for that: + + countGoodNews: protocol.preEvent("good-news", function (news) { + this.amountOfGoodNews++; + }); + +You can have events wait until an asynchronous action completes before firing by returning a promise. If you have multiple preEvents defined for a specific event, and atleast one fires asynchronously, then all preEvents most resolve before all events are fired. + + countGoodNews: protocol.preEvent("good-news", function (news) { + return this.updateGoodNews().then(() => this.amountOfGoodNews++); + }); + +On a somewhat related note, not every method needs to be request/response. Just like an actor can emit a one-way event, a method can be marked as a one-way request. Maybe we don't care about giveGoodNews returning anything: + + // spec: + methods: { + giveGoodNews: { + request: { news: Arg(0, "string") }, + oneway: true + } + } + + // implementation: + giveGoodNews: function (news) { + emit(this, "good-news", news); + } + +Lifetimes +--------- + +No, let's talk about custom front methods instead. + +Custom Front Methods +-------------------- + +You might have some bookkeeping to do before issuing a request. Let's say you're calling `echo`, but you want to count the number of times you issue that request. Just use the `custom` tag in your front implementation: + + echo: custom(function (str) { + this.numEchos++; + return this._echo(str); + }, { + impl: "_echo" + }) + +This puts the generated implementation in `_echo` instead of `echo`, letting you implement `echo` as needed. If you leave out the `impl`, it just won't generate the implementation at all. You're on your own. + +Lifetimes +--------- + +OK, I can't think of any more ways to put this off. The remote debugging protocol has the concept of a *parent* for each actor. This is to make distributed memory management a bit easier. Basically, any descendents of an actor will be destroyed if the actor is destroyed. + +Other than that, the basic protocol makes no guarantees about lifetime. Each interface defined in the protocol will need to discuss and document its approach to lifetime management (although there are a few common patterns). + +The protocol library will maintain the child/parent relationships for you, but it needs some help deciding what the child/parent relationships are. + +The default parent of an object is the first object that returns it after it is created. So to revisit our earlier HelloActor `getChild` implementation: + + // spec: + methods: { + getChild: { + request: { id: Arg(0) }, + response: { child: RetVal("childActor") } + } + } + + // implementation: + getChild: function (id) { + return new ChildActor(this.conn, id); + } + +The ChildActor's parent is the HelloActor, because it's the one that created it. + +You can customize this behavior in two ways. The first is by defining a `marshallPool` property in your actor. Imagine a new ChildActor method: + + // spec: + methods: { + getSibling: { + request: { id: Arg(0) }, + response: { child: RetVal("childActor") } + } + } + + // implementation: + getSibling: function (id) { + return new ChildActor(this.conn, id); + } + +This creates a new child actor owned by the current child actor. But in this example we want all actors created by the child to be owned by the HelloActor. So we can define a `defaultParent` property that makes use of the `parent` proeprty provided by the Actor class: + + get marshallPool() { return this.parent } + +The front needs to provide a matching `defaultParent` property that returns an owning front, to make sure the client and server lifetimes stay synced. + +For more complex situations, you can define your own lifetime properties. Take this new pair of HelloActor methods: + + // When the "temp" lifetime is specified, look for the _temporaryParent attribute as the owner. + types.addLifetime("temp", "_temporaryParent"); + + // spec: + methods: { + getTemporaryChild: { + request: { id: Arg(0) }, + response: { + child: RetVal("temp:childActor") // use the lifetime name here to specify the expected lifetime. + } + }, + clearTemporaryChildren: { + oneway: true + } + } + + // implementation: + getTemporaryChild: function (id) { + if (!this._temporaryParent) { + // Create an actor to serve as the parent for all temporary children and explicitly + // add it as a child of this actor. + this._temporaryParent = this.manage(new Actor(this.conn)); + } + return new ChildActor(this.conn, id); + } + + clearTemporaryChildren: function () { + if (this._temporaryParent) { + this._temporaryParent.destroy(); + delete this._temporaryParent; + } + } + +This will require some matching work on the front: + + getTemporaryChild: protocol.custom(function (id) { + if (!this._temporaryParent) { + this._temporaryParent = this.manage(new Front(this.client)); + } + return this._getTemporaryChild(id); + }, { + impl: "_getTemporaryChild" + }), + + clearTemporaryChildren: protocol.custom(function (id) { + if (this._temporaryParent) { + this._temporaryParent.destroy(); + delete this._temporaryParent; + } + return this._clearTemporaryChildren(); + }, { + impl: "_clearTemporaryChildren" + }) diff --git a/devtools/server/event-parsers.js b/devtools/server/event-parsers.js new file mode 100644 index 000000000..a813d8e9b --- /dev/null +++ b/devtools/server/event-parsers.js @@ -0,0 +1,369 @@ +/* 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/. */ + +// This file contains event parsers that are then used by developer tools in +// order to find information about events affecting an HTML element. + +"use strict"; + +const {Cc, Ci, Cu} = require("chrome"); + +loader.lazyGetter(this, "eventListenerService", () => { + return Cc["@mozilla.org/eventlistenerservice;1"] + .getService(Ci.nsIEventListenerService); +}); + +var parsers = [ + { + id: "jQuery events", + getListeners: function (node) { + let global = node.ownerGlobal.wrappedJSObject; + let hasJQuery = global.jQuery && global.jQuery.fn && global.jQuery.fn.jquery; + + if (!hasJQuery) { + return; + } + + let jQuery = global.jQuery; + let handlers = []; + + // jQuery 1.2+ + let data = jQuery._data || jQuery.data; + if (data) { + let eventsObj = data(node, "events"); + for (let type in eventsObj) { + let events = eventsObj[type]; + for (let key in events) { + let event = events[key]; + if (typeof event === "object" || typeof event === "function") { + let eventInfo = { + type: type, + handler: event.handler || event, + tags: "jQuery", + hide: { + capturing: true, + dom0: true + } + }; + + handlers.push(eventInfo); + } + } + } + } + + // JQuery 1.0 & 1.1 + let entry = jQuery(node)[0]; + + if (!entry) { + return handlers; + } + + for (let type in entry.events) { + let events = entry.events[type]; + for (let key in events) { + if (typeof events[key] === "function") { + let eventInfo = { + type: type, + handler: events[key], + tags: "jQuery", + hide: { + capturing: true, + dom0: true + } + }; + + handlers.push(eventInfo); + } + } + } + + return handlers; + } + }, + { + id: "jQuery live events", + hasListeners: function (node) { + return jQueryLiveGetListeners(node, true); + }, + getListeners: function (node) { + return jQueryLiveGetListeners(node, false); + }, + normalizeHandler: function (handlerDO) { + let paths = [ + [".event.proxy/", ".event.proxy/", "*"], + [".proxy/", "*"] + ]; + + let name = handlerDO.displayName; + + if (!name) { + return handlerDO; + } + + for (let path of paths) { + if (name.includes(path[0])) { + path.splice(0, 1); + + for (let point of path) { + let names = handlerDO.environment.names(); + + for (let varName of names) { + let temp = handlerDO.environment.getVariable(varName); + if (!temp) { + continue; + } + + let displayName = temp.displayName; + if (!displayName) { + continue; + } + + if (temp.class === "Function" && + (displayName.includes(point) || point === "*")) { + handlerDO = temp; + break; + } + } + } + break; + } + } + + return handlerDO; + } + }, + { + id: "DOM events", + hasListeners: function (node) { + let listeners; + + if (node.nodeName.toLowerCase() === "html") { + listeners = eventListenerService.getListenerInfoFor(node.ownerGlobal) || []; + } else { + listeners = eventListenerService.getListenerInfoFor(node) || []; + } + + for (let listener of listeners) { + if (listener.listenerObject && listener.type) { + return true; + } + } + + return false; + }, + getListeners: function (node) { + let handlers = []; + let listeners = eventListenerService.getListenerInfoFor(node); + + // The Node actor's getEventListenerInfo knows that when an html tag has + // been passed we need the window object so we don't need to account for + // event hoisting here as we did in hasListeners. + + for (let listenerObj of listeners) { + let listener = listenerObj.listenerObject; + + // If there is no JS event listener skip this. + if (!listener) { + continue; + } + + let eventInfo = { + capturing: listenerObj.capturing, + type: listenerObj.type, + handler: listener + }; + + handlers.push(eventInfo); + } + + return handlers; + } + } +]; + +function jQueryLiveGetListeners(node, boolOnEventFound) { + let global = node.ownerGlobal.wrappedJSObject; + let hasJQuery = global.jQuery && global.jQuery.fn && global.jQuery.fn.jquery; + + if (!hasJQuery) { + return; + } + + let jQuery = global.jQuery; + let handlers = []; + let data = jQuery._data || jQuery.data; + + if (data) { + // Live events are added to the document and bubble up to all elements. + // Any element matching the specified selector will trigger the live + // event. + let events = data(global.document, "events"); + + for (let type in events) { + let eventHolder = events[type]; + + for (let idx in eventHolder) { + if (typeof idx !== "string" || isNaN(parseInt(idx, 10))) { + continue; + } + + let event = eventHolder[idx]; + let selector = event.selector; + + if (!selector && event.data) { + selector = event.data.selector || event.data || event.selector; + } + + if (!selector || !node.ownerDocument) { + continue; + } + + let matches; + try { + matches = node.matches && node.matches(selector); + } catch (e) { + // Invalid selector, do nothing. + } + + if (boolOnEventFound && matches) { + return true; + } + + if (!matches) { + continue; + } + + if (!boolOnEventFound && (typeof event === "object" || typeof event === "function")) { + let eventInfo = { + type: event.origType || event.type.substr(selector.length + 1), + handler: event.handler || event, + tags: "jQuery,Live", + hide: { + dom0: true, + capturing: true + } + }; + + if (!eventInfo.type && event.data && event.data.live) { + eventInfo.type = event.data.live; + } + + handlers.push(eventInfo); + } + } + } + } + + if (boolOnEventFound) { + return false; + } + return handlers; +} + +this.EventParsers = function EventParsers() { + if (this._eventParsers.size === 0) { + for (let parserObj of parsers) { + this.registerEventParser(parserObj); + } + } +}; + +exports.EventParsers = EventParsers; + +EventParsers.prototype = { + _eventParsers: new Map(), // NOTE: This is shared amongst all instances. + + get parsers() { + return this._eventParsers; + }, + + /** + * Register a new event parser to be used in the processing of event info. + * + * @param {Object} parserObj + * Each parser must contain the following properties: + * - parser, which must take the following form: + * { + * id {String}: "jQuery events", // Unique id. + * getListeners: function(node) { }, // Function that takes a node and + * // returns an array of eventInfo + * // objects (see below). + * + * hasListeners: function(node) { }, // Optional function that takes a + * // node and returns a boolean + * // indicating whether a node has + * // listeners attached. + * + * normalizeHandler: function(fnDO) { }, // Optional function that takes a + * // Debugger.Object instance and + * // climbs the scope chain to get + * // the function that should be + * // displayed in the event bubble + * // see the following url for + * // details: + * // https://developer.mozilla.org/ + * // docs/Tools/Debugger-API/ + * // Debugger.Object + * } + * + * An eventInfo object should take the following form: + * { + * type {String}: "click", + * handler {Function}: event handler, + * tags {String}: "jQuery,Live", // These tags will be displayed as + * // attributes in the events popup. + * hide: { // Hide or show fields: + * debugger: false, // Debugger icon + * type: false, // Event type e.g. click + * filename: false, // Filename + * capturing: false, // Capturing + * dom0: false // DOM 0 + * }, + * + * override: { // The following can be overridden: + * type: "click", + * origin: "http://www.mozilla.com", + * searchString: 'onclick="doSomething()"', + * DOM0: true, + * capturing: true + * } + * } + */ + registerEventParser: function (parserObj) { + let parserId = parserObj.id; + + if (!parserId) { + throw new Error("Cannot register new event parser with id " + parserId); + } + if (this._eventParsers.has(parserId)) { + throw new Error("Duplicate event parser id " + parserId); + } + + this._eventParsers.set(parserId, { + getListeners: parserObj.getListeners, + hasListeners: parserObj.hasListeners, + normalizeHandler: parserObj.normalizeHandler + }); + }, + + /** + * Removes parser that matches a given parserId. + * + * @param {String} parserId + * id of the event parser to unregister. + */ + unregisterEventParser: function (parserId) { + this._eventParsers.delete(parserId); + }, + + /** + * Tidy up parsers. + */ + destroy: function () { + for (let [id] of this._eventParsers) { + this.unregisterEventParser(id, true); + } + } +}; diff --git a/devtools/server/main.js b/devtools/server/main.js new file mode 100644 index 000000000..475995493 --- /dev/null +++ b/devtools/server/main.js @@ -0,0 +1,1902 @@ +/* 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"; + +/** + * Toolkit glue for the remote debugging protocol, loaded into the + * debugging global. + */ +var { Ci, Cc, CC, Cu, Cr } = require("chrome"); +var Services = require("Services"); +var { ActorPool, OriginalLocation, RegisteredActorFactory, + ObservedActorFactory } = require("devtools/server/actors/common"); +var { LocalDebuggerTransport, ChildDebuggerTransport, WorkerDebuggerTransport } = + require("devtools/shared/transport/transport"); +var DevToolsUtils = require("devtools/shared/DevToolsUtils"); +var { dumpn, dumpv } = DevToolsUtils; +var flags = require("devtools/shared/flags"); +var EventEmitter = require("devtools/shared/event-emitter"); +var Promise = require("promise"); +var SyncPromise = require("devtools/shared/deprecated-sync-thenables"); + +DevToolsUtils.defineLazyGetter(this, "DebuggerSocket", () => { + let { DebuggerSocket } = require("devtools/shared/security/socket"); + return DebuggerSocket; +}); +DevToolsUtils.defineLazyGetter(this, "Authentication", () => { + return require("devtools/shared/security/auth"); +}); +DevToolsUtils.defineLazyGetter(this, "generateUUID", () => { + let { generateUUID } = Cc["@mozilla.org/uuid-generator;1"] + .getService(Ci.nsIUUIDGenerator); + return generateUUID; +}); + +// On B2G, `this` != Global scope, so `Ci` won't be binded on `this` +// (i.e. this.Ci is undefined) Then later, when using loadSubScript, +// Ci,... won't be defined for sub scripts. +this.Ci = Ci; +this.Cc = Cc; +this.CC = CC; +this.Cu = Cu; +this.Cr = Cr; +this.Services = Services; +this.ActorPool = ActorPool; +this.DevToolsUtils = DevToolsUtils; +this.dumpn = dumpn; +this.dumpv = dumpv; + +// Overload `Components` to prevent SDK loader exception on Components +// object usage +Object.defineProperty(this, "Components", { + get() { + return require("chrome").components; + } +}); + +if (isWorker) { + flags.wantLogging = true; + flags.wantVerbose = true; +} else { + const LOG_PREF = "devtools.debugger.log"; + const VERBOSE_PREF = "devtools.debugger.log.verbose"; + + flags.wantLogging = Services.prefs.getBoolPref(LOG_PREF); + flags.wantVerbose = + Services.prefs.getPrefType(VERBOSE_PREF) !== Services.prefs.PREF_INVALID && + Services.prefs.getBoolPref(VERBOSE_PREF); +} + +function loadSubScript(url) { + try { + let loader = Cc["@mozilla.org/moz/jssubscript-loader;1"] + .getService(Ci.mozIJSSubScriptLoader); + loader.loadSubScript(url, this); + } catch (e) { + let errorStr = "Error loading: " + url + ":\n" + + (e.fileName ? "at " + e.fileName + " : " + e.lineNumber + "\n" : "") + + e + " - " + e.stack + "\n"; + dump(errorStr); + reportError(errorStr); + throw e; + } +} + +loader.lazyRequireGetter(this, "events", "sdk/event/core"); + +var gRegisteredModules = Object.create(null); + +/** + * The ModuleAPI object is passed to modules loaded using the + * DebuggerServer.registerModule() API. Modules can use this + * object to register actor factories. + * Factories registered through the module API will be removed + * when the module is unregistered or when the server is + * destroyed. + */ +function ModuleAPI() { + let activeTabActors = new Set(); + let activeGlobalActors = new Set(); + + return { + // See DebuggerServer.setRootActor for a description. + setRootActor(factory) { + DebuggerServer.setRootActor(factory); + }, + + // See DebuggerServer.addGlobalActor for a description. + addGlobalActor(factory, name) { + DebuggerServer.addGlobalActor(factory, name); + activeGlobalActors.add(factory); + }, + // See DebuggerServer.removeGlobalActor for a description. + removeGlobalActor(factory) { + DebuggerServer.removeGlobalActor(factory); + activeGlobalActors.delete(factory); + }, + + // See DebuggerServer.addTabActor for a description. + addTabActor(factory, name) { + DebuggerServer.addTabActor(factory, name); + activeTabActors.add(factory); + }, + // See DebuggerServer.removeTabActor for a description. + removeTabActor(factory) { + DebuggerServer.removeTabActor(factory); + activeTabActors.delete(factory); + }, + + // Destroy the module API object, unregistering any + // factories registered by the module. + destroy() { + for (let factory of activeTabActors) { + DebuggerServer.removeTabActor(factory); + } + activeTabActors = null; + for (let factory of activeGlobalActors) { + DebuggerServer.removeGlobalActor(factory); + } + activeGlobalActors = null; + } + }; +} + +/** * + * Public API + */ +var DebuggerServer = { + _listeners: [], + _initialized: false, + // Map of global actor names to actor constructors provided by extensions. + globalActorFactories: {}, + // Map of tab actor names to actor constructors provided by extensions. + tabActorFactories: {}, + + LONG_STRING_LENGTH: 10000, + LONG_STRING_INITIAL_LENGTH: 1000, + LONG_STRING_READ_LENGTH: 65 * 1024, + + /** + * The windowtype of the chrome window to use for actors that use the global + * window (i.e the global style editor). Set this to your main window type, + * for example "navigator:browser". + */ + chromeWindowType: null, + + /** + * Allow debugging chrome of (parent or child) processes. + */ + allowChromeProcess: false, + + /** + * Initialize the debugger server. + */ + init() { + if (this.initialized) { + return; + } + + this._connections = {}; + this._nextConnID = 0; + + this._initialized = true; + }, + + get protocol() { + return require("devtools/shared/protocol"); + }, + + get initialized() { + return this._initialized; + }, + + /** + * Performs cleanup tasks before shutting down the debugger server. Such tasks + * include clearing any actor constructors added at runtime. This method + * should be called whenever a debugger server is no longer useful, to avoid + * memory leaks. After this method returns, the debugger server must be + * initialized again before use. + */ + destroy() { + if (!this._initialized) { + return; + } + + for (let connID of Object.getOwnPropertyNames(this._connections)) { + this._connections[connID].close(); + } + + for (let id of Object.getOwnPropertyNames(gRegisteredModules)) { + this.unregisterModule(id); + } + gRegisteredModules = Object.create(null); + + this.closeAllListeners(); + this.globalActorFactories = {}; + this.tabActorFactories = {}; + this._initialized = false; + + dumpn("Debugger server is shut down."); + }, + + /** + * Raises an exception if the server has not been properly initialized. + */ + _checkInit() { + if (!this._initialized) { + throw new Error("DebuggerServer has not been initialized."); + } + + if (!this.createRootActor) { + throw new Error("Use DebuggerServer.addActors() to add a root actor " + + "implementation."); + } + }, + + /** + * Load a subscript into the debugging global. + * + * @param url string A url that will be loaded as a subscript into the + * debugging global. The user must load at least one script + * that implements a createRootActor() function to create the + * server's root actor. + */ + addActors(url) { + loadSubScript.call(this, url); + }, + + /** + * Register a CommonJS module with the debugger server. + * @param id string + * The ID of a CommonJS module. This module must export 'register' + * and 'unregister' functions if no `options` argument is given. + * If `options` is set, the actor is going to be registered + * immediately, but loaded only when a client starts sending packets + * to an actor with the same id. + * + * @param options object (optional) + * This parameter is still optional, but not providing it is + * deprecated and will result in eagerly loading the actor module + * with the memory overhead that entails. + * An object with 3 mandatory attributes: + * - prefix (string): + * The prefix of an actor is used to compute: + * - the `actorID` of each new actor instance (ex: prefix1). + * (See ActorPool.addActor) + * - the actor name in the listTabs request. Sending a listTabs + * request to the root actor returns actor IDs. IDs are in + * dictionaries, with actor names as keys and actor IDs as values. + * The actor name is the prefix to which the "Actor" string is + * appended. So for an actor with the `console` prefix, the actor + * name will be `consoleActor`. + * - constructor (string): + * the name of the exported symbol to be used as the actor + * constructor. + * - type (a dictionary of booleans with following attribute names): + * - "global" + * registers a global actor instance, if true. + * A global actor has the root actor as its parent. + * - "tab" + * registers a tab actor instance, if true. + * A new actor will be created for each tab and each app. + */ + registerModule(id, options) { + if (id in gRegisteredModules) { + throw new Error("Tried to register a module twice: " + id + "\n"); + } + + if (options) { + // Lazy loaded actors + let {prefix, constructor, type} = options; + if (typeof (prefix) !== "string") { + throw new Error(`Lazy actor definition for '${id}' requires a string ` + + `'prefix' option.`); + } + if (typeof (constructor) !== "string") { + throw new Error(`Lazy actor definition for '${id}' requires a string ` + + `'constructor' option.`); + } + if (!("global" in type) && !("tab" in type)) { + throw new Error(`Lazy actor definition for '${id}' requires a dictionary ` + + `'type' option whose attributes can be 'global' or 'tab'.`); + } + let name = prefix + "Actor"; + let mod = { + id: id, + prefix: prefix, + constructorName: constructor, + type: type, + globalActor: type.global, + tabActor: type.tab + }; + gRegisteredModules[id] = mod; + if (mod.tabActor) { + this.addTabActor(mod, name); + } + if (mod.globalActor) { + this.addGlobalActor(mod, name); + } + } else { + // Deprecated actors being loaded at startup + let moduleAPI = ModuleAPI(); + let mod = require(id); + mod.register(moduleAPI); + gRegisteredModules[id] = { + module: mod, + api: moduleAPI + }; + } + }, + + /** + * Returns true if a module id has been registered. + */ + isModuleRegistered(id) { + return (id in gRegisteredModules); + }, + + /** + * Unregister a previously-loaded CommonJS module from the debugger server. + */ + unregisterModule(id) { + let mod = gRegisteredModules[id]; + if (!mod) { + throw new Error("Tried to unregister a module that was not previously registered."); + } + + // Lazy actors + if (mod.tabActor) { + this.removeTabActor(mod); + } + if (mod.globalActor) { + this.removeGlobalActor(mod); + } + + if (mod.module) { + // Deprecated non-lazy module API + mod.module.unregister(mod.api); + mod.api.destroy(); + } + + delete gRegisteredModules[id]; + }, + + /** + * Install Firefox-specific actors. + * + * /!\ Be careful when adding a new actor, especially global actors. + * Any new global actor will be exposed and returned by the root actor. + * + * That's the reason why tab actors aren't loaded on demand via + * restrictPrivileges=true, to prevent exposing them on b2g parent process's + * root actor. + */ + addBrowserActors(windowType = "navigator:browser", restrictPrivileges = false) { + this.chromeWindowType = windowType; + this.registerModule("devtools/server/actors/webbrowser"); + + if (!restrictPrivileges) { + this.addTabActors(); + this.registerModule("devtools/server/actors/preference", { + prefix: "preference", + constructor: "PreferenceActor", + type: { global: true } + }); + this.registerModule("devtools/server/actors/actor-registry", { + prefix: "actorRegistry", + constructor: "ActorRegistryActor", + type: { global: true } + }); + } + if (Services.prefs.getBoolPref("dom.mozSettings.enabled")) { + this.registerModule("devtools/server/actors/settings", { + prefix: "settings", + constructor: "SettingsActor", + type: { global: true } + }); + } + this.registerModule("devtools/server/actors/addons", { + prefix: "addons", + constructor: "AddonsActor", + type: { global: true } + }); + this.registerModule("devtools/server/actors/device", { + prefix: "device", + constructor: "DeviceActor", + type: { global: true } + }); + this.registerModule("devtools/server/actors/director-registry", { + prefix: "directorRegistry", + constructor: "DirectorRegistryActor", + type: { global: true } + }); + this.registerModule("devtools/server/actors/heap-snapshot-file", { + prefix: "heapSnapshotFile", + constructor: "HeapSnapshotFileActor", + type: { global: true } + }); + }, + + /** + * Install tab actors in documents loaded in content childs + */ + addChildActors() { + // In case of apps being loaded in parent process, DebuggerServer is already + // initialized and browser actors are already loaded, + // but childtab.js hasn't been loaded yet. + if (!DebuggerServer.tabActorFactories.hasOwnProperty("consoleActor")) { + this.addTabActors(); + } + // But webbrowser.js and childtab.js aren't loaded from shell.js. + if (!this.isModuleRegistered("devtools/server/actors/webbrowser")) { + this.registerModule("devtools/server/actors/webbrowser"); + } + if (!("ContentActor" in this)) { + this.addActors("resource://devtools/server/actors/childtab.js"); + } + }, + + /** + * Install tab actors. + */ + addTabActors() { + this.registerModule("devtools/server/actors/webconsole", { + prefix: "console", + constructor: "WebConsoleActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/inspector", { + prefix: "inspector", + constructor: "InspectorActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/call-watcher", { + prefix: "callWatcher", + constructor: "CallWatcherActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/canvas", { + prefix: "canvas", + constructor: "CanvasActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/webgl", { + prefix: "webgl", + constructor: "WebGLActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/webaudio", { + prefix: "webaudio", + constructor: "WebAudioActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/stylesheets", { + prefix: "styleSheets", + constructor: "StyleSheetsActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/styleeditor", { + prefix: "styleEditor", + constructor: "StyleEditorActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/storage", { + prefix: "storage", + constructor: "StorageActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/gcli", { + prefix: "gcli", + constructor: "GcliActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/memory", { + prefix: "memory", + constructor: "MemoryActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/framerate", { + prefix: "framerate", + constructor: "FramerateActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/eventlooplag", { + prefix: "eventLoopLag", + constructor: "EventLoopLagActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/reflow", { + prefix: "reflow", + constructor: "ReflowActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/css-properties", { + prefix: "cssProperties", + constructor: "CssPropertiesActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/csscoverage", { + prefix: "cssUsage", + constructor: "CSSUsageActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/monitor", { + prefix: "monitor", + constructor: "MonitorActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/timeline", { + prefix: "timeline", + constructor: "TimelineActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/director-manager", { + prefix: "directorManager", + constructor: "DirectorManagerActor", + type: { global: false, tab: true } + }); + if ("nsIProfiler" in Ci) { + this.registerModule("devtools/server/actors/profiler", { + prefix: "profiler", + constructor: "ProfilerActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/performance", { + prefix: "performance", + constructor: "PerformanceActor", + type: { tab: true } + }); + } + this.registerModule("devtools/server/actors/animation", { + prefix: "animations", + constructor: "AnimationsActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/promises", { + prefix: "promises", + constructor: "PromisesActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/performance-entries", { + prefix: "performanceEntries", + constructor: "PerformanceEntriesActor", + type: { tab: true } + }); + this.registerModule("devtools/server/actors/emulation", { + prefix: "emulation", + constructor: "EmulationActor", + type: { tab: true } + }); + }, + + /** + * Passes a set of options to the BrowserAddonActors for the given ID. + * + * @param id string + * The ID of the add-on to pass the options to + * @param options object + * The options. + * @return a promise that will be resolved when complete. + */ + setAddonOptions(id, options) { + if (!this._initialized) { + return Promise.resolve(); + } + + let promises = []; + + // Pass to all connections + for (let connID of Object.getOwnPropertyNames(this._connections)) { + promises.push(this._connections[connID].setAddonOptions(id, options)); + } + + return SyncPromise.all(promises); + }, + + get listeningSockets() { + return this._listeners.length; + }, + + /** + * Creates a socket listener for remote debugger connections. + * + * After calling this, set some socket options, such as the port / path to + * listen on, and then call |open| on the listener. + * + * See SocketListener in devtools/shared/security/socket.js for available + * options. + * + * @return SocketListener + * A SocketListener instance that is waiting to be configured and + * opened is returned. This single listener can be closed at any + * later time by calling |close| on the SocketListener. If remote + * connections are disabled, an error is thrown. + */ + createListener() { + if (!Services.prefs.getBoolPref("devtools.debugger.remote-enabled")) { + throw new Error("Can't create listener, remote debugging disabled"); + } + this._checkInit(); + return DebuggerSocket.createListener(); + }, + + /** + * Add a SocketListener instance to the server's set of active + * SocketListeners. This is called by a SocketListener after it is opened. + */ + _addListener(listener) { + this._listeners.push(listener); + }, + + /** + * Remove a SocketListener instance from the server's set of active + * SocketListeners. This is called by a SocketListener after it is closed. + */ + _removeListener(listener) { + this._listeners = this._listeners.filter(l => l !== listener); + }, + + /** + * Closes and forgets all previously opened listeners. + * + * @return boolean + * Whether any listeners were actually closed. + */ + closeAllListeners() { + if (!this.listeningSockets) { + return false; + } + + for (let listener of this._listeners) { + listener.close(); + } + + return true; + }, + + /** + * Creates a new connection to the local debugger speaking over a fake + * transport. This connection results in straightforward calls to the onPacket + * handlers of each side. + * + * @param prefix string [optional] + * If given, all actors in this connection will have names starting + * with |prefix + '/'|. + * @returns a client-side DebuggerTransport for communicating with + * the newly-created connection. + */ + connectPipe(prefix) { + this._checkInit(); + + let serverTransport = new LocalDebuggerTransport(); + let clientTransport = new LocalDebuggerTransport(serverTransport); + serverTransport.other = clientTransport; + let connection = this._onConnection(serverTransport, prefix); + + // I'm putting this here because I trust you. + // + // There are times, when using a local connection, when you're going + // to be tempted to just get direct access to the server. Resist that + // temptation! If you succumb to that temptation, you will make the + // fine developers that work on Fennec and Firefox OS sad. They're + // professionals, they'll try to act like they understand, but deep + // down you'll know that you hurt them. + // + // This reference allows you to give in to that temptation. There are + // times this makes sense: tests, for example, and while porting a + // previously local-only codebase to the remote protocol. + // + // But every time you use this, you will feel the shame of having + // used a property that starts with a '_'. + clientTransport._serverConnection = connection; + + return clientTransport; + }, + + /** + * In a content child process, create a new connection that exchanges + * nsIMessageSender messages with our parent process. + * + * @param prefix + * The prefix we should use in our nsIMessageSender message names and + * actor names. This connection will use messages named + * "debug:<prefix>:packet", and all its actors will have names + * beginning with "<prefix>/". + */ + connectToParent(prefix, scopeOrManager) { + this._checkInit(); + + let transport = isWorker ? + new WorkerDebuggerTransport(scopeOrManager, prefix) : + new ChildDebuggerTransport(scopeOrManager, prefix); + + return this._onConnection(transport, prefix, true); + }, + + connectToContent(connection, mm) { + let deferred = SyncPromise.defer(); + + let prefix = connection.allocID("content-process"); + let actor, childTransport; + + mm.addMessageListener("debug:content-process-actor", function listener(msg) { + // Arbitrarily choose the first content process to reply + // XXX: This code needs to be updated if we use more than one content process + mm.removeMessageListener("debug:content-process-actor", listener); + + // Pipe Debugger message from/to parent/child via the message manager + childTransport = new ChildDebuggerTransport(mm, prefix); + childTransport.hooks = { + onPacket: connection.send.bind(connection), + onClosed() {} + }; + childTransport.ready(); + + connection.setForwarding(prefix, childTransport); + + dumpn("establishing forwarding for process with prefix " + prefix); + + actor = msg.json.actor; + + deferred.resolve(actor); + }); + + mm.sendAsyncMessage("DevTools:InitDebuggerServer", { + prefix: prefix + }); + + function onClose() { + Services.obs.removeObserver(onMessageManagerClose, "message-manager-close"); + events.off(connection, "closed", onClose); + if (childTransport) { + // If we have a child transport, the actor has already + // been created. We need to stop using this message manager. + childTransport.close(); + childTransport = null; + connection.cancelForwarding(prefix); + + // ... and notify the child process to clean the tab actors. + try { + mm.sendAsyncMessage("debug:content-process-destroy"); + } catch (e) { + // Nothing to do + } + } + } + + let onMessageManagerClose = DevToolsUtils.makeInfallible((subject, topic, data) => { + if (subject == mm) { + onClose(); + connection.send({ from: actor.actor, type: "tabDetached" }); + } + }); + Services.obs.addObserver(onMessageManagerClose, + "message-manager-close", false); + + events.on(connection, "closed", onClose); + + return deferred.promise; + }, + + connectToWorker(connection, dbg, id, options) { + return new Promise((resolve, reject) => { + // Step 1: Ensure the worker debugger is initialized. + if (!dbg.isInitialized) { + dbg.initialize("resource://devtools/server/worker.js"); + + // Create a listener for rpc requests from the worker debugger. Only do + // this once, when the worker debugger is first initialized, rather than + // for each connection. + let listener = { + onClose: () => { + dbg.removeListener(listener); + }, + + onMessage: (message) => { + message = JSON.parse(message); + if (message.type !== "rpc") { + return; + } + + Promise.resolve().then(() => { + let method = { + "fetch": DevToolsUtils.fetch, + }[message.method]; + if (!method) { + throw Error("Unknown method: " + message.method); + } + + return method.apply(undefined, message.params); + }).then((value) => { + dbg.postMessage(JSON.stringify({ + type: "rpc", + result: value, + error: null, + id: message.id + })); + }, (reason) => { + dbg.postMessage(JSON.stringify({ + type: "rpc", + result: null, + error: reason, + id: message.id + })); + }); + } + }; + + dbg.addListener(listener); + } + + // Step 2: Send a connect request to the worker debugger. + dbg.postMessage(JSON.stringify({ + type: "connect", + id, + options, + })); + + // Steps 3-5 are performed on the worker thread (see worker.js). + + // Step 6: Wait for a connection response from the worker debugger. + let listener = { + onClose: () => { + dbg.removeListener(listener); + + reject("closed"); + }, + + onMessage: (message) => { + message = JSON.parse(message); + if (message.type !== "connected" || message.id !== id) { + return; + } + + // The initial connection message has been received, don't + // need to listen any longer + dbg.removeListener(listener); + + // Step 7: Create a transport for the connection to the worker. + let transport = new WorkerDebuggerTransport(dbg, id); + transport.ready(); + transport.hooks = { + onClosed: () => { + if (!dbg.isClosed) { + // If the worker happens to be shutting down while we are trying + // to close the connection, there is a small interval during + // which no more runnables can be dispatched to the worker, but + // the worker debugger has not yet been closed. In that case, + // the call to postMessage below will fail. The onClosed hook on + // DebuggerTransport is not supposed to throw exceptions, so we + // need to make sure to catch these early. + try { + dbg.postMessage(JSON.stringify({ + type: "disconnect", + id, + })); + } catch (e) { + // We can safely ignore these exceptions. The only time the + // call to postMessage can fail is if the worker is either + // shutting down, or has finished shutting down. In both + // cases, there is nothing to clean up, so we don't care + // whether this message arrives or not. + } + } + + connection.cancelForwarding(id); + }, + + onPacket: (packet) => { + // Ensure that any packets received from the server on the worker + // thread are forwarded to the client on the main thread, as if + // they had been sent by the server on the main thread. + connection.send(packet); + } + }; + + // Ensure that any packets received from the client on the main thread + // to actors on the worker thread are forwarded to the server on the + // worker thread. + connection.setForwarding(id, transport); + + resolve({ + threadActor: message.threadActor, + consoleActor: message.consoleActor, + transport: transport + }); + } + }; + dbg.addListener(listener); + }); + }, + + /** + * Check if the server is running in the child process. + */ + get isInChildProcess() { + return Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; + }, + + /** + * In a chrome parent process, ask all content child processes + * to execute a given module setup helper. + * + * @param module + * The module to be required + * @param setupChild + * The name of the setup helper exported by the above module + * (setup helper signature: function ({mm}) { ... }) + * @param waitForEval (optional) + * If true, the returned promise only resolves once code in child + * is evaluated + */ + setupInChild({ module, setupChild, args, waitForEval }) { + if (this._childMessageManagers.size == 0) { + return Promise.resolve(); + } + let deferred = Promise.defer(); + + // If waitForEval is set, pass a unique id and expect child.js to send + // a message back once the code in child is evaluated. + if (typeof (waitForEval) != "boolean") { + waitForEval = false; + } + let count = this._childMessageManagers.size; + let id = waitForEval ? generateUUID().toString() : null; + + this._childMessageManagers.forEach(mm => { + if (waitForEval) { + // Listen for the end of each child execution + let evalListener = msg => { + if (msg.data.id !== id) { + return; + } + mm.removeMessageListener("debug:setup-in-child-response", evalListener); + if (--count === 0) { + deferred.resolve(); + } + }; + mm.addMessageListener("debug:setup-in-child-response", evalListener); + } + mm.sendAsyncMessage("debug:setup-in-child", { + module: module, + setupChild: setupChild, + args: args, + id: id, + }); + }); + + if (waitForEval) { + return deferred.promise; + } + return Promise.resolve(); + }, + + /** + * Live list of all currenctly attached child's message managers. + */ + _childMessageManagers: new Set(), + + /** + * Connect to a child process. + * + * @param object connection + * The debugger server connection to use. + * @param nsIDOMElement frame + * The browser element that holds the child process. + * @param function [onDestroy] + * Optional function to invoke when the child process closes + * or the connection shuts down. (Need to forget about the + * related TabActor) + * @return object + * A promise object that is resolved once the connection is + * established. + */ + connectToChild(connection, frame, onDestroy) { + let deferred = SyncPromise.defer(); + + // Get messageManager from XUL browser (which might be a specialized tunnel for RDM) + // or else fallback to asking the frameLoader itself. + let mm = frame.messageManager || frame.frameLoader.messageManager; + mm.loadFrameScript("resource://devtools/server/child.js", false); + + let trackMessageManager = () => { + frame.addEventListener("DevTools:BrowserSwap", onBrowserSwap); + mm.addMessageListener("debug:setup-in-parent", onSetupInParent); + if (!actor) { + mm.addMessageListener("debug:actor", onActorCreated); + } + DebuggerServer._childMessageManagers.add(mm); + }; + + let untrackMessageManager = () => { + frame.removeEventListener("DevTools:BrowserSwap", onBrowserSwap); + mm.removeMessageListener("debug:setup-in-parent", onSetupInParent); + if (!actor) { + mm.removeMessageListener("debug:actor", onActorCreated); + } + DebuggerServer._childMessageManagers.delete(mm); + }; + + let actor, childTransport; + let prefix = connection.allocID("child"); + // Compute the same prefix that's used by DebuggerServerConnection + let connPrefix = prefix + "/"; + + // provides hook to actor modules that need to exchange messages + // between e10s parent and child processes + let parentModules = []; + let onSetupInParent = function (msg) { + // We may have multiple connectToChild instance running for the same tab + // and need to filter the messages. + if (msg.json.prefix != connPrefix) { + return false; + } + + let { module, setupParent } = msg.json; + let m; + + try { + m = require(module); + + if (!setupParent in m) { + dumpn(`ERROR: module '${module}' does not export '${setupParent}'`); + return false; + } + + parentModules.push(m[setupParent]({ mm, prefix: connPrefix })); + + return true; + } catch (e) { + let errorMessage = + "Exception during actor module setup running in the parent process: "; + DevToolsUtils.reportException(errorMessage + e); + dumpn(`ERROR: ${errorMessage}\n\t module: '${module}'\n\t ` + + `setupParent: '${setupParent}'\n${DevToolsUtils.safeErrorString(e)}`); + return false; + } + }; + + let onActorCreated = DevToolsUtils.makeInfallible(function (msg) { + if (msg.json.prefix != prefix) { + return; + } + mm.removeMessageListener("debug:actor", onActorCreated); + + // Pipe Debugger message from/to parent/child via the message manager + childTransport = new ChildDebuggerTransport(mm, prefix); + childTransport.hooks = { + onPacket: connection.send.bind(connection), + onClosed() {} + }; + childTransport.ready(); + + connection.setForwarding(prefix, childTransport); + + dumpn("establishing forwarding for app with prefix " + prefix); + + actor = msg.json.actor; + deferred.resolve(actor); + }).bind(this); + + // Listen for browser frame swap + let onBrowserSwap = ({ detail: newFrame }) => { + // Remove listeners from old frame and mm + untrackMessageManager(); + // Update frame and mm to point to the new browser frame + frame = newFrame; + // Get messageManager from XUL browser (which might be a specialized tunnel for RDM) + // or else fallback to asking the frameLoader itself. + mm = frame.messageManager || frame.frameLoader.messageManager; + // Add listeners to new frame and mm + trackMessageManager(); + + // provides hook to actor modules that need to exchange messages + // between e10s parent and child processes + parentModules.forEach(mod => { + if (mod.onBrowserSwap) { + mod.onBrowserSwap(mm); + } + }); + + if (childTransport) { + childTransport.swapBrowser(mm); + } + }; + + let destroy = DevToolsUtils.makeInfallible(function () { + // provides hook to actor modules that need to exchange messages + // between e10s parent and child processes + parentModules.forEach(mod => { + if (mod.onDisconnected) { + mod.onDisconnected(); + } + }); + // TODO: Remove this deprecated path once it's no longer needed by add-ons. + DebuggerServer.emit("disconnected-from-child:" + connPrefix, + { mm, prefix: connPrefix }); + + if (childTransport) { + // If we have a child transport, the actor has already + // been created. We need to stop using this message manager. + childTransport.close(); + childTransport = null; + connection.cancelForwarding(prefix); + + // ... and notify the child process to clean the tab actors. + try { + // Bug 1169643: Ignore any exception as the child process + // may already be destroyed by now. + mm.sendAsyncMessage("debug:disconnect", { prefix }); + } catch (e) { + // Nothing to do + } + } else { + // Otherwise, the app has been closed before the actor + // had a chance to be created, so we are not able to create + // the actor. + deferred.resolve(null); + } + if (actor) { + // The ContentActor within the child process doesn't necessary + // have time to uninitialize itself when the app is closed/killed. + // So ensure telling the client that the related actor is detached. + connection.send({ from: actor.actor, type: "tabDetached" }); + actor = null; + } + + if (onDestroy) { + onDestroy(mm); + } + + // Cleanup all listeners + untrackMessageManager(); + Services.obs.removeObserver(onMessageManagerClose, "message-manager-close"); + events.off(connection, "closed", destroy); + }); + + // Listen for various messages and frame events + trackMessageManager(); + + // Listen for app process exit + let onMessageManagerClose = function (subject, topic, data) { + if (subject == mm) { + destroy(); + } + }; + Services.obs.addObserver(onMessageManagerClose, + "message-manager-close", false); + + // Listen for connection close to cleanup things + // when user unplug the device or we lose the connection somehow. + events.on(connection, "closed", destroy); + + mm.sendAsyncMessage("debug:connect", { prefix }); + + return deferred.promise; + }, + + /** + * Create a new debugger connection for the given transport. Called after + * connectPipe(), from connectToParent, or from an incoming socket + * connection handler. + * + * If present, |forwardingPrefix| is a forwarding prefix that a parent + * server is using to recognizes messages intended for this server. Ensure + * that all our actors have names beginning with |forwardingPrefix + '/'|. + * In particular, the root actor's name will be |forwardingPrefix + '/root'|. + */ + _onConnection(transport, forwardingPrefix, noRootActor = false) { + let connID; + if (forwardingPrefix) { + connID = forwardingPrefix + "/"; + } else { + // Multiple servers can be started at the same time, and when that's the + // case, they are loaded in separate devtools loaders. + // So, use the current loader ID to prefix the connection ID and make it + // unique. + connID = "server" + loader.id + ".conn" + this._nextConnID++ + "."; + } + + let conn = new DebuggerServerConnection(connID, transport); + this._connections[connID] = conn; + + // Create a root actor for the connection and send the hello packet. + if (!noRootActor) { + conn.rootActor = this.createRootActor(conn); + if (forwardingPrefix) { + conn.rootActor.actorID = forwardingPrefix + "/root"; + } else { + conn.rootActor.actorID = "root"; + } + conn.addActor(conn.rootActor); + transport.send(conn.rootActor.sayHello()); + } + transport.ready(); + + this.emit("connectionchange", "opened", conn); + return conn; + }, + + /** + * Remove the connection from the debugging server. + */ + _connectionClosed(connection) { + delete this._connections[connection.prefix]; + this.emit("connectionchange", "closed", connection); + }, + + // DebuggerServer extension API. + + setRootActor(actorFactory) { + this.createRootActor = actorFactory; + }, + + /** + * Registers handlers for new tab-scoped request types defined dynamically. + * This is used for example by add-ons to augment the functionality of the tab + * actor. Note that the name or actorPrefix of the request type is not allowed + * to clash with existing protocol packet properties, like 'title', 'url' or + * 'actor', since that would break the protocol. + * + * @param actor function, object + * In case of function: + * The constructor function for this request type. This expects to be + * called as a constructor (i.e. with 'new'), and passed two + * arguments: the DebuggerServerConnection, and the BrowserTabActor + * with which it will be associated. + * Only used for deprecated eagerly loaded actors. + * In case of object: + * First argument of RegisteredActorFactory constructor. + * See the it's definition for more info. + * + * @param name string [optional] + * The name of the new request type. If this is not present, the + * actorPrefix property of the constructor prototype is used. + */ + addTabActor(actor, name = actor.prototype.actorPrefix) { + if (["title", "url", "actor"].indexOf(name) != -1) { + throw Error(name + " is not allowed"); + } + if (DebuggerServer.tabActorFactories.hasOwnProperty(name)) { + throw Error(name + " already exists"); + } + DebuggerServer.tabActorFactories[name] = new RegisteredActorFactory(actor, name); + }, + + /** + * Unregisters the handler for the specified tab-scoped request type. + * This may be used for example by add-ons when shutting down or upgrading. + * When unregistering an existing tab actor remove related tab factory + * as well as all existing instances of the actor. + * + * @param actor function, object + * In case of function: + * The constructor function for this request type. + * In case of object: + * Same object being given to related addTabActor call. + */ + removeTabActor(actor) { + for (let name in DebuggerServer.tabActorFactories) { + let handler = DebuggerServer.tabActorFactories[name]; + if ((handler.name && handler.name == actor.name) || + (handler.id && handler.id == actor.id)) { + delete DebuggerServer.tabActorFactories[name]; + for (let connID of Object.getOwnPropertyNames(this._connections)) { + // DebuggerServerConnection in child process don't have rootActor + if (this._connections[connID].rootActor) { + this._connections[connID].rootActor.removeActorByName(name); + } + } + } + } + }, + + /** + * Registers handlers for new browser-scoped request types defined + * dynamically. This is used for example by add-ons to augment the + * functionality of the root actor. Note that the name or actorPrefix of the + * request type is not allowed to clash with existing protocol packet + * properties, like 'from', 'tabs' or 'selected', since that would break the + * protocol. + * + * @param actor function, object + * In case of function: + * The constructor function for this request type. This expects to be + * called as a constructor (i.e. with 'new'), and passed two + * arguments: the DebuggerServerConnection, and the BrowserRootActor + * with which it will be associated. + * Only used for deprecated eagerly loaded actors. + * In case of object: + * First argument of RegisteredActorFactory constructor. + * See the it's definition for more info. + * + * @param name string [optional] + * The name of the new request type. If this is not present, the + * actorPrefix property of the constructor prototype is used. + */ + addGlobalActor(actor, name = actor.prototype.actorPrefix) { + if (["from", "tabs", "selected"].indexOf(name) != -1) { + throw Error(name + " is not allowed"); + } + if (DebuggerServer.globalActorFactories.hasOwnProperty(name)) { + throw Error(name + " already exists"); + } + DebuggerServer.globalActorFactories[name] = new RegisteredActorFactory(actor, name); + }, + + /** + * Unregisters the handler for the specified browser-scoped request type. + * This may be used for example by add-ons when shutting down or upgrading. + * When unregistering an existing global actor remove related global factory + * as well as all existing instances of the actor. + * + * @param actor function, object + * In case of function: + * The constructor function for this request type. + * In case of object: + * Same object being given to related addGlobalActor call. + */ + removeGlobalActor(actor) { + for (let name in DebuggerServer.globalActorFactories) { + let handler = DebuggerServer.globalActorFactories[name]; + if ((handler.name && handler.name == actor.name) || + (handler.id && handler.id == actor.id)) { + delete DebuggerServer.globalActorFactories[name]; + for (let connID of Object.getOwnPropertyNames(this._connections)) { + this._connections[connID].rootActor.removeActorByName(name); + } + } + } + }, + + /** + * âš TESTING ONLY! âš Searches all active connections for an actor matching an ID. + * This is helpful for some tests which depend on reaching into the server to check some + * properties of an actor. + */ + _searchAllConnectionsForActor(actorID) { + for (let connID of Object.getOwnPropertyNames(this._connections)) { + let actor = this._connections[connID].getActor(actorID); + if (actor) { + return actor; + } + } + return null; + }, +}; + +// Expose these to save callers the trouble of importing DebuggerSocket +DevToolsUtils.defineLazyGetter(DebuggerServer, "Authenticators", () => { + return Authentication.Authenticators; +}); +DevToolsUtils.defineLazyGetter(DebuggerServer, "AuthenticationResult", () => { + return Authentication.AuthenticationResult; +}); + +EventEmitter.decorate(DebuggerServer); + +if (this.exports) { + exports.DebuggerServer = DebuggerServer; + exports.ActorPool = ActorPool; + exports.OriginalLocation = OriginalLocation; +} + +// Needed on B2G (See header note) +this.DebuggerServer = DebuggerServer; +this.ActorPool = ActorPool; +this.OriginalLocation = OriginalLocation; + +// When using DebuggerServer.addActors, some symbols are expected to be in +// the scope of the added actor even before the corresponding modules are +// loaded, so let's explicitly bind the expected symbols here. +var includes = ["Components", "Ci", "Cu", "require", "Services", "DebuggerServer", + "ActorPool", "DevToolsUtils"]; +includes.forEach(name => { + DebuggerServer[name] = this[name]; +}); + +/** + * Creates a DebuggerServerConnection. + * + * Represents a connection to this debugging global from a client. + * Manages a set of actors and actor pools, allocates actor ids, and + * handles incoming requests. + * + * @param prefix string + * All actor IDs created by this connection should be prefixed + * with prefix. + * @param transport transport + * Packet transport for the debugging protocol. + */ +function DebuggerServerConnection(prefix, transport) { + this._prefix = prefix; + this._transport = transport; + this._transport.hooks = this; + this._nextID = 1; + + this._actorPool = new ActorPool(this); + this._extraPools = [this._actorPool]; + + // Responses to a given actor must be returned the the client + // in the same order as the requests that they're replying to, but + // Implementations might finish serving requests in a different + // order. To keep things in order we generate a promise for each + // request, chained to the promise for the request before it. + // This map stores the latest request promise in the chain, keyed + // by an actor ID string. + this._actorResponses = new Map(); + + /* + * We can forward packets to other servers, if the actors on that server + * all use a distinct prefix on their names. This is a map from prefixes + * to transports: it maps a prefix P to a transport T if T conveys + * packets to the server whose actors' names all begin with P + "/". + */ + this._forwardingPrefixes = new Map(); +} + +DebuggerServerConnection.prototype = { + _prefix: null, + get prefix() { + return this._prefix; + }, + + _transport: null, + get transport() { + return this._transport; + }, + + /** + * Message manager used to communicate with the parent process, + * set by child.js. Is only defined for connections instantiated + * within a child process. + */ + parentMessageManager: null, + + close() { + if (this._transport) { + this._transport.close(); + } + }, + + send(packet) { + this.transport.send(packet); + }, + + /** + * Used when sending a bulk reply from an actor. + * @see DebuggerTransport.prototype.startBulkSend + */ + startBulkSend(header) { + return this.transport.startBulkSend(header); + }, + + allocID(prefix) { + return this.prefix + (prefix || "") + this._nextID++; + }, + + /** + * Add a map of actor IDs to the connection. + */ + addActorPool(actorPool) { + this._extraPools.push(actorPool); + }, + + /** + * Remove a previously-added pool of actors to the connection. + * + * @param ActorPool actorPool + * The ActorPool instance you want to remove. + * @param boolean noCleanup [optional] + * True if you don't want to disconnect each actor from the pool, false + * otherwise. + */ + removeActorPool(actorPool, noCleanup) { + // When a connection is closed, it removes each of its actor pools. When an + // actor pool is removed, it calls the disconnect method on each of its + // actors. Some actors, such as ThreadActor, manage their own actor pools. + // When the disconnect method is called on these actors, they manually + // remove their actor pools. Consequently, this method is reentrant. + // + // In addition, some actors, such as ThreadActor, perform asynchronous work + // (in the case of ThreadActor, because they need to resume), before they + // remove each of their actor pools. Since we don't wait for this work to + // be completed, we can end up in this function recursively after the + // connection already set this._extraPools to null. + // + // This is a bug: if the disconnect method can perform asynchronous work, + // then we should wait for that work to be completed before setting this. + // _extraPools to null. As a temporary solution, it should be acceptable + // to just return early (if this._extraPools has been set to null, all + // actors pools for this connection should already have been removed). + if (this._extraPools === null) { + return; + } + let index = this._extraPools.lastIndexOf(actorPool); + if (index > -1) { + let pool = this._extraPools.splice(index, 1); + if (!noCleanup) { + pool.forEach(p => p.destroy()); + } + } + }, + + /** + * Add an actor to the default actor pool for this connection. + */ + addActor(actor) { + this._actorPool.addActor(actor); + }, + + /** + * Remove an actor to the default actor pool for this connection. + */ + removeActor(actor) { + this._actorPool.removeActor(actor); + }, + + /** + * Match the api expected by the protocol library. + */ + unmanage(actor) { + return this.removeActor(actor); + }, + + /** + * Look up an actor implementation for an actorID. Will search + * all the actor pools registered with the connection. + * + * @param actorID string + * Actor ID to look up. + */ + getActor(actorID) { + let pool = this.poolFor(actorID); + if (pool) { + return pool.get(actorID); + } + + if (actorID === "root") { + return this.rootActor; + } + + return null; + }, + + _getOrCreateActor(actorID) { + let actor = this.getActor(actorID); + if (!actor) { + this.transport.send({ from: actorID ? actorID : "root", + error: "noSuchActor", + message: "No such actor for ID: " + actorID }); + return null; + } + + // Dynamically-loaded actors have to be created lazily. + if (actor instanceof ObservedActorFactory) { + try { + actor = actor.createActor(); + } catch (e) { + this.transport.send(this._unknownError( + "Error occurred while creating actor '" + actor.name, + e)); + } + } else if (typeof (actor) !== "object") { + // ActorPools should now contain only actor instances (i.e. objects) + // or ObservedActorFactory instances. + throw new Error("Unexpected actor constructor/function in ActorPool " + + "for actorID=" + actorID + "."); + } + + return actor; + }, + + poolFor(actorID) { + for (let pool of this._extraPools) { + if (pool.has(actorID)) { + return pool; + } + } + return null; + }, + + _unknownError(prefix, error) { + let errorString = prefix + ": " + DevToolsUtils.safeErrorString(error); + reportError(errorString); + dumpn(errorString); + return { + error: "unknownError", + message: errorString + }; + }, + + _queueResponse: function (from, type, responseOrPromise) { + let pendingResponse = this._actorResponses.get(from) || SyncPromise.resolve(null); + let responsePromise = pendingResponse.then(() => { + return responseOrPromise; + }).then(response => { + if (!response.from) { + response.from = from; + } + this.transport.send(response); + }).then(null, (e) => { + let errorPacket = this._unknownError( + "error occurred while processing '" + type, e); + errorPacket.from = from; + this.transport.send(errorPacket); + }); + + this._actorResponses.set(from, responsePromise); + }, + + /** + * Passes a set of options to the BrowserAddonActors for the given ID. + * + * @param id string + * The ID of the add-on to pass the options to + * @param options object + * The options. + * @return a promise that will be resolved when complete. + */ + setAddonOptions(id, options) { + let addonList = this.rootActor._parameters.addonList; + if (!addonList) { + return SyncPromise.resolve(); + } + return addonList.getList().then((addonActors) => { + for (let actor of addonActors) { + if (actor.id != id) { + continue; + } + actor.setOptions(options); + return; + } + }); + }, + + /* Forwarding packets to other transports based on actor name prefixes. */ + + /* + * Arrange to forward packets to another server. This is how we + * forward debugging connections to child processes. + * + * If we receive a packet for an actor whose name begins with |prefix| + * followed by '/', then we will forward that packet to |transport|. + * + * This overrides any prior forwarding for |prefix|. + * + * @param prefix string + * The actor name prefix, not including the '/'. + * @param transport object + * A packet transport to which we should forward packets to actors + * whose names begin with |(prefix + '/').| + */ + setForwarding(prefix, transport) { + this._forwardingPrefixes.set(prefix, transport); + }, + + /* + * Stop forwarding messages to actors whose names begin with + * |prefix+'/'|. Such messages will now elicit 'noSuchActor' errors. + */ + cancelForwarding(prefix) { + this._forwardingPrefixes.delete(prefix); + + // Notify the client that forwarding in now cancelled for this prefix. + // There could be requests in progress that the client should abort rather leaving + // handing indefinitely. + if (this.rootActor) { + this.send(this.rootActor.forwardingCancelled(prefix)); + } + }, + + sendActorEvent(actorID, eventName, event = {}) { + event.from = actorID; + event.type = eventName; + this.send(event); + }, + + // Transport hooks. + + /** + * Called by DebuggerTransport to dispatch incoming packets as appropriate. + * + * @param packet object + * The incoming packet. + */ + onPacket(packet) { + // If the actor's name begins with a prefix we've been asked to + // forward, do so. + // + // Note that the presence of a prefix alone doesn't indicate that + // forwarding is needed: in DebuggerServerConnection instances in child + // processes, every actor has a prefixed name. + if (this._forwardingPrefixes.size > 0) { + let to = packet.to; + let separator = to.lastIndexOf("/"); + while (separator >= 0) { + to = to.substring(0, separator); + let forwardTo = this._forwardingPrefixes.get(packet.to.substring(0, separator)); + if (forwardTo) { + forwardTo.send(packet); + return; + } + separator = to.lastIndexOf("/"); + } + } + + let actor = this._getOrCreateActor(packet.to); + if (!actor) { + return; + } + + let ret = null; + + // handle "requestTypes" RDP request. + if (packet.type == "requestTypes") { + ret = { from: actor.actorID, requestTypes: Object.keys(actor.requestTypes) }; + } else if (actor.requestTypes && actor.requestTypes[packet.type]) { + // Dispatch the request to the actor. + try { + this.currentPacket = packet; + ret = actor.requestTypes[packet.type].bind(actor)(packet, this); + } catch (e) { + this.transport.send(this._unknownError( + "error occurred while processing '" + packet.type, + e)); + } finally { + this.currentPacket = undefined; + } + } else { + ret = { error: "unrecognizedPacketType", + message: ("Actor " + actor.actorID + + " does not recognize the packet type " + + packet.type) }; + } + + // There will not be a return value if a bulk reply is sent. + if (ret) { + this._queueResponse(packet.to, packet.type, ret); + } + }, + + /** + * Called by the DebuggerTransport to dispatch incoming bulk packets as + * appropriate. + * + * @param packet object + * The incoming packet, which contains: + * * actor: Name of actor that will receive the packet + * * type: Name of actor's method that should be called on receipt + * * length: Size of the data to be read + * * stream: This input stream should only be used directly if you can + * ensure that you will read exactly |length| bytes and will + * not close the stream when reading is complete + * * done: If you use the stream directly (instead of |copyTo| + * below), you must signal completion by resolving / + * rejecting this deferred. If it's rejected, the transport + * will be closed. If an Error is supplied as a rejection + * value, it will be logged via |dumpn|. If you do use + * |copyTo|, resolving is taken care of for you when copying + * completes. + * * copyTo: A helper function for getting your data out of the stream + * that meets the stream handling requirements above, and has + * the following signature: + * @param output nsIAsyncOutputStream + * The stream to copy to. + * @return Promise + * The promise is resolved when copying completes or rejected + * if any (unexpected) errors occur. + * This object also emits "progress" events for each chunk + * that is copied. See stream-utils.js. + */ + onBulkPacket(packet) { + let { actor: actorKey, type } = packet; + + let actor = this._getOrCreateActor(actorKey); + if (!actor) { + return; + } + + // Dispatch the request to the actor. + let ret; + if (actor.requestTypes && actor.requestTypes[type]) { + try { + ret = actor.requestTypes[type].call(actor, packet); + } catch (e) { + this.transport.send(this._unknownError( + "error occurred while processing bulk packet '" + type, e)); + packet.done.reject(e); + } + } else { + let message = "Actor " + actorKey + + " does not recognize the bulk packet type " + type; + ret = { error: "unrecognizedPacketType", + message: message }; + packet.done.reject(new Error(message)); + } + + // If there is a JSON response, queue it for sending back to the client. + if (ret) { + this._queueResponse(actorKey, type, ret); + } + }, + + /** + * Called by DebuggerTransport when the underlying stream is closed. + * + * @param status nsresult + * The status code that corresponds to the reason for closing + * the stream. + */ + onClosed(status) { + dumpn("Cleaning up connection."); + if (!this._actorPool) { + // Ignore this call if the connection is already closed. + return; + } + this._actorPool = null; + + events.emit(this, "closed", status); + + this._extraPools.forEach(p => p.destroy()); + this._extraPools = null; + + this.rootActor = null; + this._transport = null; + DebuggerServer._connectionClosed(this); + }, + + /* + * Debugging helper for inspecting the state of the actor pools. + */ + _dumpPools() { + dumpn("/-------------------- dumping pools:"); + if (this._actorPool) { + dumpn("--------------------- actorPool actors: " + + uneval(Object.keys(this._actorPool._actors))); + } + for (let pool of this._extraPools) { + if (pool !== this._actorPool) { + dumpn("--------------------- extraPool actors: " + + uneval(Object.keys(pool._actors))); + } + } + }, + + /* + * Debugging helper for inspecting the state of an actor pool. + */ + _dumpPool(pool) { + dumpn("/-------------------- dumping pool:"); + dumpn("--------------------- actorPool actors: " + + uneval(Object.keys(pool._actors))); + }, + + /** + * In a content child process, ask the DebuggerServer in the parent process + * to execute a given module setup helper. + * + * @param module + * The module to be required + * @param setupParent + * The name of the setup helper exported by the above module + * (setup helper signature: function ({mm}) { ... }) + * @return boolean + * true if the setup helper returned successfully + */ + setupInParent({ module, setupParent }) { + if (!this.parentMessageManager) { + return false; + } + + let { sendSyncMessage } = this.parentMessageManager; + + return sendSyncMessage("debug:setup-in-parent", { + prefix: this.prefix, + module: module, + setupParent: setupParent + }); + }, +}; diff --git a/devtools/server/moz.build b/devtools/server/moz.build new file mode 100644 index 000000000..383505fe2 --- /dev/null +++ b/devtools/server/moz.build @@ -0,0 +1,42 @@ +# -*- 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/. + +include('../templates.mozbuild') + +DIRS += [ + 'actors', + 'performance', + 'shims', +] + +BROWSER_CHROME_MANIFESTS += ['tests/browser/browser.ini'] +MOCHITEST_CHROME_MANIFESTS += ['tests/mochitest/chrome.ini'] +XPCSHELL_TESTS_MANIFESTS += ['tests/unit/xpcshell.ini'] + +XPIDL_SOURCES += [ + 'nsIJSInspector.idl', +] + +XPIDL_MODULE = 'jsinspector' + +SOURCES += [ + 'nsJSInspector.cpp', +] + +FINAL_LIBRARY = 'xul' + +DevToolsModules( + 'child.js', + 'content-globals.js', + 'content-server.jsm', + 'css-logic.js', + 'event-parsers.js', + 'main.js', + 'primitive.js', + 'service-worker-child.js', + 'websocket-server.js', + 'worker.js' +) diff --git a/devtools/server/nsIJSInspector.idl b/devtools/server/nsIJSInspector.idl new file mode 100644 index 000000000..40ad49523 --- /dev/null +++ b/devtools/server/nsIJSInspector.idl @@ -0,0 +1,75 @@ +/* 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/. */ + +#include "nsISupports.idl" + +/** + * Utilities for running nested event loops, asking them to return, and + * keeping track of which ones are still running. + */ +[scriptable, uuid(6758d0d7-e96a-4c5c-bca8-3bcbe5a15943)] +interface nsIJSInspector : nsISupports +{ + /** + * Process the current thread's event queue, calling event handlers until + * a call to exitNestedEventLoop, below, asks us to return. + * + * The name 'enterNestedEventLoop' may be misleading if read too literally. + * This method loops calling event handlers until one asks it to stop, and + * then returns. So by that point, the nested event loop has been not only + * entered, but also run and exited. + * + * When enterNestedEventLoop calls an event handler, that handler may itself + * call enterNestedEventLoop, and so on, so that there may be arbitrarily + * many such calls on the stack at the same time. + * + * We say an enterNestedEventLoop call is "running" if it has not yet been + * asked to return, or "stopped" if it has been asked to return once it has + * finished processing the current event. + * + * @param requestor A token of the caller's choice to identify this event + * loop. + * + * @return depth The number of running enterNestedEventLoop calls + * remaining, now that this one has returned. + * + * (Note that not all calls still on the stack are + * necessary running; exitNestedEventLoop can ask any + * number of enterNestedEventLoop calls to return.) + */ + unsigned long enterNestedEventLoop(in jsval requestor); + + /** + * Stop the youngest running enterNestedEventLoop call, asking it to return + * once it has finished processing the current event. + * + * The name 'exitNestedEventLoop' may be misleading if read too literally. + * The affected event loop does not return immediately when this method is + * called. Rather, this method simply returns to its caller; the affected + * loop's current event handler is allowed to run to completion; and then + * that loop returns without processing any more events. + * + * This method ignores loops that have already been stopped, and operates on + * the youngest loop that is still running. Each call to this method stops + * another running loop. + * + * @return depth The number of running enterNestedEventLoop calls + * remaining, now that one has been stopped. + * + * @throws NS_ERROR_FAILURE if there are no running enterNestedEventLoop calls. + */ + unsigned long exitNestedEventLoop(); + + /** + * The number of running enterNestedEventLoop calls on the stack. + * This count does not include stopped enterNestedEventLoop calls. + */ + readonly attribute unsigned long eventLoopNestLevel; + + /** + * The |requestor| value that was passed to the youngest running + * enterNestedEventLoop call. + */ + readonly attribute jsval lastNestRequestor; +}; diff --git a/devtools/server/nsJSInspector.cpp b/devtools/server/nsJSInspector.cpp new file mode 100644 index 000000000..6d717af5b --- /dev/null +++ b/devtools/server/nsJSInspector.cpp @@ -0,0 +1,147 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#include "nsJSInspector.h" +#include "nsIXPConnect.h" +#include "nsThreadUtils.h" +#include "jsfriendapi.h" +#include "mozilla/HoldDropJSObjects.h" +#include "mozilla/ModuleUtils.h" +#include "mozilla/dom/ScriptSettings.h" +#include "nsServiceManagerUtils.h" +#include "nsMemory.h" +#include "nsArray.h" +#include "nsTArray.h" + +#define JSINSPECTOR_CONTRACTID \ + "@mozilla.org/jsinspector;1" + +#define JSINSPECTOR_CID \ +{ 0xec5aa99c, 0x7abb, 0x4142, { 0xac, 0x5f, 0xaa, 0xb2, 0x41, 0x9e, 0x38, 0xe2 } } + +namespace mozilla { +namespace jsinspector { + +NS_GENERIC_FACTORY_CONSTRUCTOR(nsJSInspector) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(nsJSInspector) + NS_INTERFACE_MAP_ENTRY(nsISupports) + NS_INTERFACE_MAP_ENTRY(nsIJSInspector) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTION_CLASS(nsJSInspector) + +NS_IMPL_CYCLE_COLLECTING_ADDREF(nsJSInspector) +NS_IMPL_CYCLE_COLLECTING_RELEASE(nsJSInspector) + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(nsJSInspector) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE_SCRIPT_OBJECTS +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(nsJSInspector) + tmp->mRequestors.Clear(); + tmp->mLastRequestor = JS::NullValue(); +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN(nsJSInspector) + for (uint32_t i = 0; i < tmp->mRequestors.Length(); ++i) { + NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mRequestors[i]) + } + NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mLastRequestor) +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +nsJSInspector::nsJSInspector() : mNestedLoopLevel(0), mRequestors(1), mLastRequestor(JS::NullValue()) +{ +} + +nsJSInspector::~nsJSInspector() +{ + MOZ_ASSERT(mRequestors.Length() == 0); + MOZ_ASSERT(mLastRequestor.isNull()); + mozilla::DropJSObjects(this); +} + +NS_IMETHODIMP +nsJSInspector::EnterNestedEventLoop(JS::Handle<JS::Value> requestor, uint32_t *out) +{ + nsresult rv = NS_OK; + + mLastRequestor = requestor; + mRequestors.AppendElement(requestor); + mozilla::HoldJSObjects(this); + + mozilla::dom::AutoNoJSAPI nojsapi; + + uint32_t nestLevel = ++mNestedLoopLevel; + while (NS_SUCCEEDED(rv) && mNestedLoopLevel >= nestLevel) { + if (!NS_ProcessNextEvent()) + rv = NS_ERROR_UNEXPECTED; + } + + NS_ASSERTION(mNestedLoopLevel <= nestLevel, + "nested event didn't unwind properly"); + + if (mNestedLoopLevel == nestLevel) { + mLastRequestor = mRequestors.ElementAt(--mNestedLoopLevel); + } + + *out = mNestedLoopLevel; + return rv; +} + +NS_IMETHODIMP +nsJSInspector::ExitNestedEventLoop(uint32_t *out) +{ + if (mNestedLoopLevel > 0) { + mRequestors.RemoveElementAt(--mNestedLoopLevel); + if (mNestedLoopLevel > 0) + mLastRequestor = mRequestors.ElementAt(mNestedLoopLevel - 1); + else + mLastRequestor = JS::NullValue(); + } else { + return NS_ERROR_FAILURE; + } + + *out = mNestedLoopLevel; + + return NS_OK; +} + +NS_IMETHODIMP +nsJSInspector::GetEventLoopNestLevel(uint32_t *out) +{ + *out = mNestedLoopLevel; + return NS_OK; +} + +NS_IMETHODIMP +nsJSInspector::GetLastNestRequestor(JS::MutableHandle<JS::Value> out) +{ + out.set(mLastRequestor); + return NS_OK; +} + +} // namespace jsinspector +} // namespace mozilla + +NS_DEFINE_NAMED_CID(JSINSPECTOR_CID); + +static const mozilla::Module::CIDEntry kJSInspectorCIDs[] = { + { &kJSINSPECTOR_CID, false, nullptr, mozilla::jsinspector::nsJSInspectorConstructor }, + { nullptr } +}; + +static const mozilla::Module::ContractIDEntry kJSInspectorContracts[] = { + { JSINSPECTOR_CONTRACTID, &kJSINSPECTOR_CID }, + { nullptr } +}; + +static const mozilla::Module kJSInspectorModule = { + mozilla::Module::kVersion, + kJSInspectorCIDs, + kJSInspectorContracts +}; + +NSMODULE_DEFN(jsinspector) = &kJSInspectorModule; diff --git a/devtools/server/nsJSInspector.h b/devtools/server/nsJSInspector.h new file mode 100644 index 000000000..4e60b0428 --- /dev/null +++ b/devtools/server/nsJSInspector.h @@ -0,0 +1,39 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef COMPONENTS_JSINSPECTOR_H +#define COMPONENTS_JSINSPECTOR_H + +#include "nsIJSInspector.h" +#include "mozilla/Attributes.h" +#include "nsCycleCollectionParticipant.h" +#include "nsTArray.h" +#include "js/Value.h" +#include "js/RootingAPI.h" + +namespace mozilla { +namespace jsinspector { + +class nsJSInspector final : public nsIJSInspector +{ +public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(nsJSInspector) + NS_DECL_NSIJSINSPECTOR + + nsJSInspector(); + +private: + ~nsJSInspector(); + + uint32_t mNestedLoopLevel; + nsTArray<JS::Heap<JS::Value> > mRequestors; + JS::Heap<JS::Value> mLastRequestor; +}; + +} // namespace jsinspector +} // namespace mozilla + +#endif diff --git a/devtools/server/performance/framerate.js b/devtools/server/performance/framerate.js new file mode 100644 index 000000000..24f8a7a6b --- /dev/null +++ b/devtools/server/performance/framerate.js @@ -0,0 +1,99 @@ +/* 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 { on, once, off, emit } = require("sdk/event/core"); +const { Class } = require("sdk/core/heritage"); + +/** + * A very simple utility for monitoring framerate. Takes a `tabActor` + * and monitors framerate over time. The actor wrapper around this + * can be found at devtools/server/actors/framerate.js + */ +var Framerate = exports.Framerate = Class({ + initialize: function (tabActor) { + this.tabActor = tabActor; + this._contentWin = tabActor.window; + this._onRefreshDriverTick = this._onRefreshDriverTick.bind(this); + this._onGlobalCreated = this._onGlobalCreated.bind(this); + on(this.tabActor, "window-ready", this._onGlobalCreated); + }, + destroy: function (conn) { + off(this.tabActor, "window-ready", this._onGlobalCreated); + this.stopRecording(); + }, + + /** + * Starts monitoring framerate, storing the frames per second. + */ + startRecording: function () { + if (this._recording) { + return; + } + this._recording = true; + this._ticks = []; + this._startTime = this.tabActor.docShell.now(); + this._rafID = this._contentWin.requestAnimationFrame(this._onRefreshDriverTick); + }, + + /** + * Stops monitoring framerate, returning the recorded values. + */ + stopRecording: function (beginAt = 0, endAt = Number.MAX_SAFE_INTEGER) { + if (!this._recording) { + return []; + } + let ticks = this.getPendingTicks(beginAt, endAt); + this.cancelRecording(); + return ticks; + }, + + /** + * Stops monitoring framerate, without returning the recorded values. + */ + cancelRecording: function () { + this._contentWin.cancelAnimationFrame(this._rafID); + this._recording = false; + this._ticks = null; + this._rafID = -1; + }, + + /** + * Returns whether this instance is currently recording. + */ + isRecording: function () { + return !!this._recording; + }, + + /** + * Gets the refresh driver ticks recorded so far. + */ + getPendingTicks: function (beginAt = 0, endAt = Number.MAX_SAFE_INTEGER) { + if (!this._ticks) { + return []; + } + return this._ticks.filter(e => e >= beginAt && e <= endAt); + }, + + /** + * Function invoked along with the refresh driver. + */ + _onRefreshDriverTick: function () { + if (!this._recording) { + return; + } + this._rafID = this._contentWin.requestAnimationFrame(this._onRefreshDriverTick); + this._ticks.push(this.tabActor.docShell.now() - this._startTime); + }, + + /** + * When the content window for the tab actor is created. + */ + _onGlobalCreated: function (win) { + if (this._recording) { + this._contentWin.cancelAnimationFrame(this._rafID); + this._rafID = this._contentWin.requestAnimationFrame(this._onRefreshDriverTick); + } + } +}); diff --git a/devtools/server/performance/memory.js b/devtools/server/performance/memory.js new file mode 100644 index 000000000..77ce348cc --- /dev/null +++ b/devtools/server/performance/memory.js @@ -0,0 +1,425 @@ +/* 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 { Cc, Ci, Cu } = require("chrome"); +const { reportException } = require("devtools/shared/DevToolsUtils"); +const { Class } = require("sdk/core/heritage"); +const { expectState } = require("devtools/server/actors/common"); +loader.lazyRequireGetter(this, "events", "sdk/event/core"); +loader.lazyRequireGetter(this, "EventTarget", "sdk/event/target", true); +loader.lazyRequireGetter(this, "DeferredTask", + "resource://gre/modules/DeferredTask.jsm", true); +loader.lazyRequireGetter(this, "StackFrameCache", + "devtools/server/actors/utils/stack", true); +loader.lazyRequireGetter(this, "ThreadSafeChromeUtils"); +loader.lazyRequireGetter(this, "HeapSnapshotFileUtils", + "devtools/shared/heapsnapshot/HeapSnapshotFileUtils"); +loader.lazyRequireGetter(this, "ChromeActor", "devtools/server/actors/chrome", + true); +loader.lazyRequireGetter(this, "ChildProcessActor", + "devtools/server/actors/child-process", true); + +/** + * A class that returns memory data for a parent actor's window. + * Using a tab-scoped actor with this instance will measure the memory footprint of its + * parent tab. Using a global-scoped actor instance however, will measure the memory + * footprint of the chrome window referenced by its root actor. + * + * To be consumed by actor's, like MemoryActor using this module to + * send information over RDP, and TimelineActor for using more light-weight + * utilities like GC events and measuring memory consumption. + */ +var Memory = exports.Memory = Class({ + extends: EventTarget, + + /** + * Requires a root actor and a StackFrameCache. + */ + initialize: function (parent, frameCache = new StackFrameCache()) { + this.parent = parent; + this._mgr = Cc["@mozilla.org/memory-reporter-manager;1"] + .getService(Ci.nsIMemoryReporterManager); + this.state = "detached"; + this._dbg = null; + this._frameCache = frameCache; + + this._onGarbageCollection = this._onGarbageCollection.bind(this); + this._emitAllocations = this._emitAllocations.bind(this); + this._onWindowReady = this._onWindowReady.bind(this); + + events.on(this.parent, "window-ready", this._onWindowReady); + }, + + destroy: function () { + events.off(this.parent, "window-ready", this._onWindowReady); + + this._mgr = null; + if (this.state === "attached") { + this.detach(); + } + }, + + get dbg() { + if (!this._dbg) { + this._dbg = this.parent.makeDebugger(); + } + return this._dbg; + }, + + /** + * Attach to this MemoryBridge. + * + * This attaches the MemoryBridge's Debugger instance so that you can start + * recording allocations or take a census of the heap. In addition, the + * MemoryBridge will start emitting GC events. + */ + attach: expectState("detached", function () { + this.dbg.addDebuggees(); + this.dbg.memory.onGarbageCollection = this._onGarbageCollection.bind(this); + this.state = "attached"; + }, "attaching to the debugger"), + + /** + * Detach from this MemoryBridge. + */ + detach: expectState("attached", function () { + this._clearDebuggees(); + this.dbg.enabled = false; + this._dbg = null; + this.state = "detached"; + }, "detaching from the debugger"), + + /** + * Gets the current MemoryBridge attach/detach state. + */ + getState: function () { + return this.state; + }, + + _clearDebuggees: function () { + if (this._dbg) { + if (this.isRecordingAllocations()) { + this.dbg.memory.drainAllocationsLog(); + } + this._clearFrames(); + this.dbg.removeAllDebuggees(); + } + }, + + _clearFrames: function () { + if (this.isRecordingAllocations()) { + this._frameCache.clearFrames(); + } + }, + + /** + * Handler for the parent actor's "window-ready" event. + */ + _onWindowReady: function ({ isTopLevel }) { + if (this.state == "attached") { + this._clearDebuggees(); + if (isTopLevel && this.isRecordingAllocations()) { + this._frameCache.initFrames(); + } + this.dbg.addDebuggees(); + } + }, + + /** + * Returns a boolean indicating whether or not allocation + * sites are being tracked. + */ + isRecordingAllocations: function () { + return this.dbg.memory.trackingAllocationSites; + }, + + /** + * Save a heap snapshot scoped to the current debuggees' portion of the heap + * graph. + * + * @param {Object|null} boundaries + * + * @returns {String} The snapshot id. + */ + saveHeapSnapshot: expectState("attached", function (boundaries = null) { + // If we are observing the whole process, then scope the snapshot + // accordingly. Otherwise, use the debugger's debuggees. + if (!boundaries) { + boundaries = this.parent instanceof ChromeActor || this.parent instanceof ChildProcessActor + ? { runtime: true } + : { debugger: this.dbg }; + } + const path = ThreadSafeChromeUtils.saveHeapSnapshot(boundaries); + return HeapSnapshotFileUtils.getSnapshotIdFromPath(path); + }, "saveHeapSnapshot"), + + /** + * Take a census of the heap. See js/src/doc/Debugger/Debugger.Memory.md for + * more information. + */ + takeCensus: expectState("attached", function () { + return this.dbg.memory.takeCensus(); + }, "taking census"), + + /** + * Start recording allocation sites. + * + * @param {number} options.probability + * The probability we sample any given allocation when recording allocations. + * Must be between 0 and 1 -- defaults to 1. + * @param {number} options.maxLogLength + * The maximum number of allocation events to keep in the + * log. If new allocs occur while at capacity, oldest + * allocations are lost. Must fit in a 32 bit signed integer. + * @param {number} options.drainAllocationsTimeout + * A number in milliseconds of how often, at least, an `allocation` event + * gets emitted (and drained), and also emits and drains on every GC event, + * resetting the timer. + */ + startRecordingAllocations: expectState("attached", function (options = {}) { + if (this.isRecordingAllocations()) { + return this._getCurrentTime(); + } + + this._frameCache.initFrames(); + + this.dbg.memory.allocationSamplingProbability = options.probability != null + ? options.probability + : 1.0; + + this.drainAllocationsTimeoutTimer = typeof options.drainAllocationsTimeout === "number" ? options.drainAllocationsTimeout : null; + + if (this.drainAllocationsTimeoutTimer != null) { + if (this._poller) { + this._poller.disarm(); + } + this._poller = new DeferredTask(this._emitAllocations, this.drainAllocationsTimeoutTimer); + this._poller.arm(); + } + + if (options.maxLogLength != null) { + this.dbg.memory.maxAllocationsLogLength = options.maxLogLength; + } + this.dbg.memory.trackingAllocationSites = true; + + return this._getCurrentTime(); + }, "starting recording allocations"), + + /** + * Stop recording allocation sites. + */ + stopRecordingAllocations: expectState("attached", function () { + if (!this.isRecordingAllocations()) { + return this._getCurrentTime(); + } + this.dbg.memory.trackingAllocationSites = false; + this._clearFrames(); + + if (this._poller) { + this._poller.disarm(); + this._poller = null; + } + + return this._getCurrentTime(); + }, "stopping recording allocations"), + + /** + * Return settings used in `startRecordingAllocations` for `probability` + * and `maxLogLength`. Currently only uses in tests. + */ + getAllocationsSettings: expectState("attached", function () { + return { + maxLogLength: this.dbg.memory.maxAllocationsLogLength, + probability: this.dbg.memory.allocationSamplingProbability + }; + }, "getting allocations settings"), + + /** + * Get a list of the most recent allocations since the last time we got + * allocations, as well as a summary of all allocations since we've been + * recording. + * + * @returns Object + * An object of the form: + * + * { + * allocations: [<index into "frames" below>, ...], + * allocationsTimestamps: [ + * <timestamp for allocations[0]>, + * <timestamp for allocations[1]>, + * ... + * ], + * allocationSizes: [ + * <bytesize for allocations[0]>, + * <bytesize for allocations[1]>, + * ... + * ], + * frames: [ + * { + * line: <line number for this frame>, + * column: <column number for this frame>, + * source: <filename string for this frame>, + * functionDisplayName: <this frame's inferred function name function or null>, + * parent: <index into "frames"> + * }, + * ... + * ], + * } + * + * The timestamps' unit is microseconds since the epoch. + * + * Subsequent `getAllocations` request within the same recording and + * tab navigation will always place the same stack frames at the same + * indices as previous `getAllocations` requests in the same + * recording. In other words, it is safe to use the index as a + * unique, persistent id for its frame. + * + * Additionally, the root node (null) is always at index 0. + * + * We use the indices into the "frames" array to avoid repeating the + * description of duplicate stack frames both when listing + * allocations, and when many stacks share the same tail of older + * frames. There shouldn't be any duplicates in the "frames" array, + * as that would defeat the purpose of this compression trick. + * + * In the future, we might want to split out a frame's "source" and + * "functionDisplayName" properties out the same way we have split + * frames out with the "frames" array. While this would further + * compress the size of the response packet, it would increase CPU + * usage to build the packet, and it should, of course, be guided by + * profiling and done only when necessary. + */ + getAllocations: expectState("attached", function () { + if (this.dbg.memory.allocationsLogOverflowed) { + // Since the last time we drained the allocations log, there have been + // more allocations than the log's capacity, and we lost some data. There + // isn't anything actionable we can do about this, but put a message in + // the browser console so we at least know that it occurred. + reportException("MemoryBridge.prototype.getAllocations", + "Warning: allocations log overflowed and lost some data."); + } + + const allocations = this.dbg.memory.drainAllocationsLog(); + const packet = { + allocations: [], + allocationsTimestamps: [], + allocationSizes: [], + }; + for (let { frame: stack, timestamp, size } of allocations) { + if (stack && Cu.isDeadWrapper(stack)) { + continue; + } + + // Safe because SavedFrames are frozen/immutable. + let waived = Cu.waiveXrays(stack); + + // Ensure that we have a form, size, and index for new allocations + // because we potentially haven't seen some or all of them yet. After this + // loop, we can rely on the fact that every frame we deal with already has + // its metadata stored. + let index = this._frameCache.addFrame(waived); + + packet.allocations.push(index); + packet.allocationsTimestamps.push(timestamp); + packet.allocationSizes.push(size); + } + + return this._frameCache.updateFramePacket(packet); + }, "getting allocations"), + + /* + * Force a browser-wide GC. + */ + forceGarbageCollection: function () { + for (let i = 0; i < 3; i++) { + Cu.forceGC(); + } + }, + + /** + * Force an XPCOM cycle collection. For more information on XPCOM cycle + * collection, see + * https://developer.mozilla.org/en-US/docs/Interfacing_with_the_XPCOM_cycle_collector#What_the_cycle_collector_does + */ + forceCycleCollection: function () { + Cu.forceCC(); + }, + + /** + * A method that returns a detailed breakdown of the memory consumption of the + * associated window. + * + * @returns object + */ + measure: function () { + let result = {}; + + let jsObjectsSize = {}; + let jsStringsSize = {}; + let jsOtherSize = {}; + let domSize = {}; + let styleSize = {}; + let otherSize = {}; + let totalSize = {}; + let jsMilliseconds = {}; + let nonJSMilliseconds = {}; + + try { + this._mgr.sizeOfTab(this.parent.window, jsObjectsSize, jsStringsSize, jsOtherSize, + domSize, styleSize, otherSize, totalSize, jsMilliseconds, nonJSMilliseconds); + result.total = totalSize.value; + result.domSize = domSize.value; + result.styleSize = styleSize.value; + result.jsObjectsSize = jsObjectsSize.value; + result.jsStringsSize = jsStringsSize.value; + result.jsOtherSize = jsOtherSize.value; + result.otherSize = otherSize.value; + result.jsMilliseconds = jsMilliseconds.value.toFixed(1); + result.nonJSMilliseconds = nonJSMilliseconds.value.toFixed(1); + } catch (e) { + reportException("MemoryBridge.prototype.measure", e); + } + + return result; + }, + + residentUnique: function () { + return this._mgr.residentUnique; + }, + + /** + * Handler for GC events on the Debugger.Memory instance. + */ + _onGarbageCollection: function (data) { + events.emit(this, "garbage-collection", data); + + // If `drainAllocationsTimeout` set, fire an allocations event with the drained log, + // which will restart the timer. + if (this._poller) { + this._poller.disarm(); + this._emitAllocations(); + } + }, + + + /** + * Called on `drainAllocationsTimeoutTimer` interval if and only if set during `startRecordingAllocations`, + * or on a garbage collection event if drainAllocationsTimeout was set. + * Drains allocation log and emits as an event and restarts the timer. + */ + _emitAllocations: function () { + events.emit(this, "allocations", this.getAllocations()); + this._poller.arm(); + }, + + /** + * Accesses the docshell to return the current process time. + */ + _getCurrentTime: function () { + return (this.parent.isRootActor ? this.parent.docShell : this.parent.originalDocShell).now(); + }, + +}); diff --git a/devtools/server/performance/moz.build b/devtools/server/performance/moz.build new file mode 100644 index 000000000..e7b1ed00c --- /dev/null +++ b/devtools/server/performance/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + 'framerate.js', + 'memory.js', + 'profiler.js', + 'recorder.js', + 'timeline.js', +) diff --git a/devtools/server/performance/profiler.js b/devtools/server/performance/profiler.js new file mode 100644 index 000000000..700d48147 --- /dev/null +++ b/devtools/server/performance/profiler.js @@ -0,0 +1,546 @@ +/* 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 { Cc, Ci, Cu } = require("chrome"); +const Services = require("Services"); +const { Class } = require("sdk/core/heritage"); +loader.lazyRequireGetter(this, "events", "sdk/event/core"); +loader.lazyRequireGetter(this, "EventTarget", "sdk/event/target", true); +loader.lazyRequireGetter(this, "DevToolsUtils", "devtools/shared/DevToolsUtils"); +loader.lazyRequireGetter(this, "DeferredTask", "resource://gre/modules/DeferredTask.jsm", true); +loader.lazyRequireGetter(this, "Task", "devtools/shared/task", true); + +// Events piped from system observers to Profiler instances. +const PROFILER_SYSTEM_EVENTS = [ + "console-api-profiler", + "profiler-started", + "profiler-stopped" +]; + +// How often the "profiler-status" is emitted by default +const BUFFER_STATUS_INTERVAL_DEFAULT = 5000; // ms + +loader.lazyGetter(this, "nsIProfilerModule", () => { + return Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler); +}); + +var DEFAULT_PROFILER_OPTIONS = { + // When using the DevTools Performance Tools, this will be overridden + // by the pref `devtools.performance.profiler.buffer-size`. + entries: Math.pow(10, 7), + // When using the DevTools Performance Tools, this will be overridden + // by the pref `devtools.performance.profiler.sample-rate-khz`. + interval: 1, + features: ["js"], + threadFilters: ["GeckoMain"] +}; + +/** + * Main interface for interacting with nsIProfiler + */ +const ProfilerManager = (function () { + let consumers = new Set(); + + return { + + // How often the "profiler-status" is emitted + _profilerStatusInterval: BUFFER_STATUS_INTERVAL_DEFAULT, + + // How many subscribers there + _profilerStatusSubscribers: 0, + + /** + * The nsIProfiler is target agnostic and interacts with the whole platform. + * Therefore, special care needs to be given to make sure different profiler + * consumers (i.e. "toolboxes") don't interfere with each other. Register + * the profiler actor instances here. + * + * @param Profiler instance + * A profiler actor class. + */ + addInstance: function (instance) { + consumers.add(instance); + + // Lazily register events + this.registerEventListeners(); + }, + + /** + * Remove the profiler actor instances here. + * + * @param Profiler instance + * A profiler actor class. + */ + removeInstance: function (instance) { + consumers.delete(instance); + + if (this.length < 0) { + let msg = "Somehow the number of started profilers is now negative."; + DevToolsUtils.reportException("Profiler", msg); + } + + if (this.length === 0) { + this.unregisterEventListeners(); + this.stop(); + } + }, + + /** + * Starts the nsIProfiler module. Doing so will discard any samples + * that might have been accumulated so far. + * + * @param {number} entries [optional] + * @param {number} interval [optional] + * @param {Array<string>} features [optional] + * @param {Array<string>} threadFilters [description] + * + * @return {object} + */ + start: function (options = {}) { + let config = this._profilerStartOptions = { + entries: options.entries || DEFAULT_PROFILER_OPTIONS.entries, + interval: options.interval || DEFAULT_PROFILER_OPTIONS.interval, + features: options.features || DEFAULT_PROFILER_OPTIONS.features, + threadFilters: options.threadFilters || DEFAULT_PROFILER_OPTIONS.threadFilters, + }; + + // The start time should be before any samples we might be + // interested in. + let currentTime = nsIProfilerModule.getElapsedTime(); + + try { + nsIProfilerModule.StartProfiler( + config.entries, + config.interval, + config.features, + config.features.length, + config.threadFilters, + config.threadFilters.length + ); + } catch (e) { + // For some reason, the profiler couldn't be started. This could happen, + // for example, when in private browsing mode. + Cu.reportError(`Could not start the profiler module: ${e.message}`); + return { started: false, reason: e, currentTime }; + } + + this._updateProfilerStatusPolling(); + + let { position, totalSize, generation } = this.getBufferInfo(); + return { started: true, position, totalSize, generation, currentTime }; + }, + + /** + * Attempts to stop the nsIProfiler module. + */ + stop: function () { + // Actually stop the profiler only if the last client has stopped profiling. + // Since this is used as a root actor, and the profiler module interacts + // with the whole platform, we need to avoid a case in which the profiler + // is stopped when there might be other clients still profiling. + if (this.length <= 1) { + nsIProfilerModule.StopProfiler(); + } + this._updateProfilerStatusPolling(); + return { started: false }; + }, + + /** + * Returns all the samples accumulated since the profiler was started, + * along with the current time. The data has the following format: + * { + * libs: string, + * meta: { + * interval: number, + * platform: string, + * ... + * }, + * threads: [{ + * samples: [{ + * frames: [{ + * line: number, + * location: string, + * category: number + * } ... ], + * name: string + * responsiveness: number + * time: number + * } ... ] + * } ... ] + * } + * + * + * @param number startTime + * Since the circular buffer will only grow as long as the profiler lives, + * the buffer can contain unwanted samples. Pass in a `startTime` to only retrieve + * samples that took place after the `startTime`, with 0 being when the profiler + * just started. + * @param boolean stringify + * Whether or not the returned profile object should be a string or not to save + * JSON parse/stringify cycle if emitting over RDP. + */ + getProfile: function (options) { + let startTime = options.startTime || 0; + let profile = options.stringify ? + nsIProfilerModule.GetProfile(startTime) : + nsIProfilerModule.getProfileData(startTime); + + return { profile: profile, currentTime: nsIProfilerModule.getElapsedTime() }; + }, + + /** + * Returns an array of feature strings, describing the profiler features + * that are available on this platform. Can be called while the profiler + * is stopped. + * + * @return {object} + */ + getFeatures: function () { + return { features: nsIProfilerModule.GetFeatures([]) }; + }, + + /** + * Returns an object with the values of the current status of the + * circular buffer in the profiler, returning `position`, `totalSize`, + * and the current `generation` of the buffer. + * + * @return {object} + */ + getBufferInfo: function () { + let position = {}, totalSize = {}, generation = {}; + nsIProfilerModule.GetBufferInfo(position, totalSize, generation); + return { + position: position.value, + totalSize: totalSize.value, + generation: generation.value + }; + }, + + /** + * Returns the configuration used that was originally passed in to start up the + * profiler. Used for tests, and does not account for others using nsIProfiler. + * + * @param {object} + */ + getStartOptions: function () { + return this._profilerStartOptions || {}; + }, + + /** + * Verifies whether or not the nsIProfiler module has started. + * If already active, the current time is also returned. + * + * @return {object} + */ + isActive: function () { + let isActive = nsIProfilerModule.IsActive(); + let elapsedTime = isActive ? nsIProfilerModule.getElapsedTime() : undefined; + let { position, totalSize, generation } = this.getBufferInfo(); + return { isActive: isActive, currentTime: elapsedTime, position, totalSize, generation }; + }, + + /** + * Returns a stringified JSON object that describes the shared libraries + * which are currently loaded into our process. Can be called while the + * profiler is stopped. + */ + getSharedLibraryInformation: function () { + return { sharedLibraryInformation: nsIProfilerModule.getSharedLibraryInformation() }; + }, + + /** + * Number of profiler instances. + * + * @return {number} + */ + get length() { + return consumers.size; + }, + + /** + * Callback for all observed notifications. + * @param object subject + * @param string topic + * @param object data + */ + observe: sanitizeHandler(function (subject, topic, data) { + let details; + + // An optional label may be specified when calling `console.profile`. + // If that's the case, stringify it and send it over with the response. + let { action, arguments: args } = subject || {}; + let profileLabel = args && args.length > 0 ? `${args[0]}` : void 0; + + // If the event was generated from `console.profile` or `console.profileEnd` + // we need to start the profiler right away and then just notify the client. + // Otherwise, we'll lose precious samples. + if (topic === "console-api-profiler" && (action === "profile" || action === "profileEnd")) { + let { isActive, currentTime } = this.isActive(); + + // Start the profiler only if it wasn't already active. Otherwise, any + // samples that might have been accumulated so far will be discarded. + if (!isActive && action === "profile") { + this.start(); + details = { profileLabel, currentTime: 0 }; + } + // Otherwise, if inactive and a call to profile end, do nothing + // and don't emit event. + else if (!isActive) { + return; + } + + // Otherwise, the profiler is already active, so just send + // to the front the current time, label, and the notification + // adds the action as well. + details = { profileLabel, currentTime }; + } + + // Propagate the event to the profiler instances that + // are subscribed to this event. + this.emitEvent(topic, { subject, topic, data, details }); + }, "ProfilerManager.observe"), + + /** + * Registers handlers for the following events to be emitted + * on active Profiler instances: + * - "console-api-profiler" + * - "profiler-started" + * - "profiler-stopped" + * - "profiler-status" + * + * The ProfilerManager listens to all events, and individual + * consumers filter which events they are interested in. + */ + registerEventListeners: function () { + if (!this._eventsRegistered) { + PROFILER_SYSTEM_EVENTS.forEach(eventName => + Services.obs.addObserver(this, eventName, false)); + this._eventsRegistered = true; + } + }, + + /** + * Unregisters handlers for all system events. + */ + unregisterEventListeners: function () { + if (this._eventsRegistered) { + PROFILER_SYSTEM_EVENTS.forEach(eventName => + Services.obs.removeObserver(this, eventName)); + this._eventsRegistered = false; + } + }, + + /** + * Takes an event name and additional data and emits them + * through each profiler instance that is subscribed to the event. + * + * @param {string} eventName + * @param {object} data + */ + emitEvent: function (eventName, data) { + let subscribers = Array.from(consumers).filter(c => c.subscribedEvents.has(eventName)); + + for (let subscriber of subscribers) { + events.emit(subscriber, eventName, data); + } + }, + + /** + * Updates the frequency that the "profiler-status" event is emitted + * during recording. + * + * @param {number} interval + */ + setProfilerStatusInterval: function (interval) { + this._profilerStatusInterval = interval; + if (this._poller) { + this._poller._delayMs = interval; + } + }, + + subscribeToProfilerStatusEvents: function () { + this._profilerStatusSubscribers++; + this._updateProfilerStatusPolling(); + }, + + unsubscribeToProfilerStatusEvents: function () { + this._profilerStatusSubscribers--; + this._updateProfilerStatusPolling(); + }, + + /** + * Will enable or disable "profiler-status" events depending on + * if there are subscribers and if the profiler is current recording. + */ + _updateProfilerStatusPolling: function () { + if (this._profilerStatusSubscribers > 0 && nsIProfilerModule.IsActive()) { + if (!this._poller) { + this._poller = new DeferredTask(this._emitProfilerStatus.bind(this), this._profilerStatusInterval); + } + this._poller.arm(); + } + // No subscribers; turn off if it exists. + else if (this._poller) { + this._poller.disarm(); + } + }, + + _emitProfilerStatus: function () { + this.emitEvent("profiler-status", this.isActive()); + this._poller.arm(); + } + }; +})(); + +/** + * The profiler actor provides remote access to the built-in nsIProfiler module. + */ +var Profiler = exports.Profiler = Class({ + extends: EventTarget, + + initialize: function () { + this.subscribedEvents = new Set(); + ProfilerManager.addInstance(this); + }, + + destroy: function () { + this.unregisterEventNotifications({ events: Array.from(this.subscribedEvents) }); + this.subscribedEvents = null; + ProfilerManager.removeInstance(this); + }, + + /** + * @see ProfilerManager.start + */ + start: function (options) { return ProfilerManager.start(options); }, + + /** + * @see ProfilerManager.stop + */ + stop: function () { return ProfilerManager.stop(); }, + + /** + * @see ProfilerManager.getProfile + */ + getProfile: function (request = {}) { return ProfilerManager.getProfile(request); }, + + /** + * @see ProfilerManager.getFeatures + */ + getFeatures: function () { return ProfilerManager.getFeatures(); }, + + /** + * @see ProfilerManager.getBufferInfo + */ + getBufferInfo: function () { return ProfilerManager.getBufferInfo(); }, + + /** + * @see ProfilerManager.getStartOptions + */ + getStartOptions: function () { return ProfilerManager.getStartOptions(); }, + + /** + * @see ProfilerManager.isActive + */ + isActive: function () { return ProfilerManager.isActive(); }, + + /** + * @see ProfilerManager.isActive + */ + getSharedLibraryInformation: function () { return ProfilerManager.getSharedLibraryInformation(); }, + + /** + * @see ProfilerManager.setProfilerStatusInterval + */ + setProfilerStatusInterval: function (interval) { return ProfilerManager.setProfilerStatusInterval(interval); }, + + /** + * Subscribes this instance to one of several events defined in + * an events array. + * - "console-api-profiler", + * - "profiler-started", + * - "profiler-stopped" + * - "profiler-status" + * + * @param {Array<string>} data.event + * @return {object} + */ + registerEventNotifications: function (data = {}) { + let response = []; + (data.events || []).forEach(e => { + if (!this.subscribedEvents.has(e)) { + if (e === "profiler-status") { + ProfilerManager.subscribeToProfilerStatusEvents(); + } + this.subscribedEvents.add(e); + response.push(e); + } + }); + return { registered: response }; + }, + + /** + * Unsubscribes this instance to one of several events defined in + * an events array. + * + * @param {Array<string>} data.event + * @return {object} + */ + unregisterEventNotifications: function (data = {}) { + let response = []; + (data.events || []).forEach(e => { + if (this.subscribedEvents.has(e)) { + if (e === "profiler-status") { + ProfilerManager.unsubscribeToProfilerStatusEvents(); + } + this.subscribedEvents.delete(e); + response.push(e); + } + }); + return { registered: response }; + }, +}); + +/** + * Checks whether or not the profiler module can currently run. + * @return boolean + */ +Profiler.canProfile = function () { + return nsIProfilerModule.CanProfile(); +}; + +/** + * JSON.stringify callback used in Profiler.prototype.observe. + */ +function cycleBreaker(key, value) { + if (key == "wrappedJSObject") { + return undefined; + } + return value; +} + +/** + * Create JSON objects suitable for transportation across the RDP, + * by breaking cycles and making a copy of the `subject` and `data` via + * JSON.stringifying those values with a replacer that omits properties + * known to introduce cycles, and then JSON.parsing the result. + * This spends some CPU cycles, but it's simple. + * + * @TODO Also wraps it in a `makeInfallible` -- is this still necessary? + * + * @param {function} handler + * @return {function} + */ +function sanitizeHandler(handler, identifier) { + return DevToolsUtils.makeInfallible(function (subject, topic, data) { + subject = (subject && !Cu.isXrayWrapper(subject) && subject.wrappedJSObject) || subject; + subject = JSON.parse(JSON.stringify(subject, cycleBreaker)); + data = (data && !Cu.isXrayWrapper(data) && data.wrappedJSObject) || data; + data = JSON.parse(JSON.stringify(data, cycleBreaker)); + + // Pass in clean data to the underlying handler + return handler.call(this, subject, topic, data); + }, identifier); +} diff --git a/devtools/server/performance/recorder.js b/devtools/server/performance/recorder.js new file mode 100644 index 000000000..fda61ca99 --- /dev/null +++ b/devtools/server/performance/recorder.js @@ -0,0 +1,494 @@ +/* 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 { Cc, Ci, Cu, Cr } = require("chrome"); +const { Task } = require("devtools/shared/task"); + +loader.lazyRequireGetter(this, "Services"); +loader.lazyRequireGetter(this, "promise"); +loader.lazyRequireGetter(this, "extend", + "sdk/util/object", true); +loader.lazyRequireGetter(this, "Class", + "sdk/core/heritage", true); +loader.lazyRequireGetter(this, "EventTarget", + "sdk/event/target", true); +loader.lazyRequireGetter(this, "events", + "sdk/event/core"); + +loader.lazyRequireGetter(this, "Memory", + "devtools/server/performance/memory", true); +loader.lazyRequireGetter(this, "Timeline", + "devtools/server/performance/timeline", true); +loader.lazyRequireGetter(this, "Profiler", + "devtools/server/performance/profiler", true); +loader.lazyRequireGetter(this, "PerformanceRecordingActor", + "devtools/server/actors/performance-recording", true); +loader.lazyRequireGetter(this, "PerformanceRecordingFront", + "devtools/server/actors/performance-recording", true); +loader.lazyRequireGetter(this, "mapRecordingOptions", + "devtools/shared/performance/recording-utils", true); +loader.lazyRequireGetter(this, "DevToolsUtils", + "devtools/shared/DevToolsUtils"); +loader.lazyRequireGetter(this, "getSystemInfo", + "devtools/shared/system", true); + +const PROFILER_EVENTS = [ + "console-api-profiler", + "profiler-started", + "profiler-stopped", + "profiler-status" +]; + +// Max time in milliseconds for the allocations event to occur, which will +// occur on every GC, or at least as often as DRAIN_ALLOCATIONS_TIMEOUT. +const DRAIN_ALLOCATIONS_TIMEOUT = 2000; + +/** + * A connection to underlying actors (profiler, memory, framerate, etc.) + * shared by all tools in a target. + * + * @param Target target + * The target owning this connection. + */ +const PerformanceRecorder = exports.PerformanceRecorder = Class({ + extends: EventTarget, + + initialize: function (conn, tabActor) { + this.conn = conn; + this.tabActor = tabActor; + + this._pendingConsoleRecordings = []; + this._recordings = []; + + this._onTimelineData = this._onTimelineData.bind(this); + this._onProfilerEvent = this._onProfilerEvent.bind(this); + }, + + /** + * Initializes a connection to the profiler and other miscellaneous actors. + * If in the process of opening, or already open, nothing happens. + * + * @param {Object} options.systemClient + * Metadata about the client's system to attach to the recording models. + * + * @return object + * A promise that is resolved once the connection is established. + */ + connect: function (options) { + if (this._connected) { + return; + } + + // Sets `this._profiler`, `this._timeline` and `this._memory`. + // Only initialize the timeline and memory fronts if the respective actors + // are available. Older Gecko versions don't have existing implementations, + // in which case all the methods we need can be easily mocked. + this._connectComponents(); + this._registerListeners(); + + this._systemClient = options.systemClient; + + this._connected = true; + }, + + /** + * Destroys this connection. + */ + destroy: function () { + this._unregisterListeners(); + this._disconnectComponents(); + + this._connected = null; + this._profiler = null; + this._timeline = null; + this._memory = null; + this._target = null; + this._client = null; + }, + + /** + * Initializes fronts and connects to the underlying actors using the facades + * found in ./actors.js. + */ + _connectComponents: function () { + this._profiler = new Profiler(this.tabActor); + this._memory = new Memory(this.tabActor); + this._timeline = new Timeline(this.tabActor); + this._profiler.registerEventNotifications({ events: PROFILER_EVENTS }); + }, + + /** + * Registers listeners on events from the underlying + * actors, so the connection can handle them. + */ + _registerListeners: function () { + this._timeline.on("*", this._onTimelineData); + this._memory.on("*", this._onTimelineData); + this._profiler.on("*", this._onProfilerEvent); + }, + + /** + * Unregisters listeners on events on the underlying actors. + */ + _unregisterListeners: function () { + this._timeline.off("*", this._onTimelineData); + this._memory.off("*", this._onTimelineData); + this._profiler.off("*", this._onProfilerEvent); + }, + + /** + * Closes the connections to non-profiler actors. + */ + _disconnectComponents: function () { + this._profiler.unregisterEventNotifications({ events: PROFILER_EVENTS }); + this._profiler.destroy(); + this._timeline.destroy(); + this._memory.destroy(); + }, + + _onProfilerEvent: function (topic, data) { + if (topic === "console-api-profiler") { + if (data.subject.action === "profile") { + this._onConsoleProfileStart(data.details); + } else if (data.subject.action === "profileEnd") { + this._onConsoleProfileEnd(data.details); + } + } else if (topic === "profiler-stopped") { + this._onProfilerUnexpectedlyStopped(); + } else if (topic === "profiler-status") { + events.emit(this, "profiler-status", data); + } + }, + + /** + * Invoked whenever `console.profile` is called. + * + * @param string profileLabel + * The provided string argument if available; undefined otherwise. + * @param number currentTime + * The time (in milliseconds) when the call was made, relative to when + * the nsIProfiler module was started. + */ + _onConsoleProfileStart: Task.async(function* ({ profileLabel, currentTime: startTime }) { + let recordings = this._recordings; + + // Abort if a profile with this label already exists. + if (recordings.find(e => e.getLabel() === profileLabel)) { + return; + } + + // Immediately emit this so the client can start setting things up, + // expecting a recording very soon. + events.emit(this, "console-profile-start"); + + let model = yield this.startRecording(extend({}, getPerformanceRecordingPrefs(), { + console: true, + label: profileLabel + })); + }), + + /** + * Invoked whenever `console.profileEnd` is called. + * + * @param string profileLabel + * The provided string argument if available; undefined otherwise. + * @param number currentTime + * The time (in milliseconds) when the call was made, relative to when + * the nsIProfiler module was started. + */ + _onConsoleProfileEnd: Task.async(function* (data) { + // If no data, abort; can occur if profiler isn't running and we get a surprise + // call to console.profileEnd() + if (!data) { + return; + } + let { profileLabel, currentTime: endTime } = data; + + let pending = this._recordings.filter(r => r.isConsole() && r.isRecording()); + if (pending.length === 0) { + return; + } + + let model; + // Try to find the corresponding `console.profile` call if + // a label was used in profileEnd(). If no matches, abort. + if (profileLabel) { + model = pending.find(e => e.getLabel() === profileLabel); + } + // If no label supplied, pop off the most recent pending console recording + else { + model = pending[pending.length - 1]; + } + + // If `profileEnd()` was called with a label, and there are no matching + // sessions, abort. + if (!model) { + Cu.reportError("console.profileEnd() called with label that does not match a recording."); + return; + } + + yield this.stopRecording(model); + }), + + /** + * TODO handle bug 1144438 + */ + _onProfilerUnexpectedlyStopped: function () { + Cu.reportError("Profiler unexpectedly stopped.", arguments); + }, + + /** + * Called whenever there is timeline data of any of the following types: + * - markers + * - frames + * - memory + * - ticks + * - allocations + */ + _onTimelineData: function (eventName, ...data) { + let eventData = Object.create(null); + + switch (eventName) { + case "markers": { + eventData = { markers: data[0], endTime: data[1] }; + break; + } + case "ticks": { + eventData = { delta: data[0], timestamps: data[1] }; + break; + } + case "memory": { + eventData = { delta: data[0], measurement: data[1] }; + break; + } + case "frames": { + eventData = { delta: data[0], frames: data[1] }; + break; + } + case "allocations": { + eventData = data[0]; + break; + } + } + + // Filter by only recordings that are currently recording; + // TODO should filter by recordings that have realtimeMarkers enabled. + let activeRecordings = this._recordings.filter(r => r.isRecording()); + + if (activeRecordings.length) { + events.emit(this, "timeline-data", eventName, eventData, activeRecordings); + } + }, + + /** + * Checks whether or not recording is currently supported. At the moment, + * this is only influenced by private browsing mode and the profiler. + */ + canCurrentlyRecord: function () { + let success = true; + let reasons = []; + + if (!Profiler.canProfile()) { + success = false, + reasons.push("profiler-unavailable"); + } + + // Check other factors that will affect the possibility of successfully + // starting a recording here. + + return { success, reasons }; + }, + + /** + * Begins a recording session + * + * @param boolean options.withMarkers + * @param boolean options.withTicks + * @param boolean options.withMemory + * @param boolean options.withAllocations + * @param boolean options.allocationsSampleProbability + * @param boolean options.allocationsMaxLogLength + * @param boolean options.bufferSize + * @param boolean options.sampleFrequency + * @param boolean options.console + * @param string options.label + * @param boolean options.realtimeMarkers + * @return object + * A promise that is resolved once recording has started. + */ + startRecording: Task.async(function* (options) { + let profilerStart, timelineStart, memoryStart; + + profilerStart = Task.spawn(function* () { + let data = yield this._profiler.isActive(); + if (data.isActive) { + return data; + } + let startData = yield this._profiler.start(mapRecordingOptions("profiler", options)); + + // If no current time is exposed from starting, set it to 0 -- this is an + // older Gecko that does not return its starting time, and uses an epoch based + // on the profiler's start time. + if (startData.currentTime == null) { + startData.currentTime = 0; + } + return startData; + }.bind(this)); + + // Timeline will almost always be on if using the DevTools, but using component + // independently could result in no timeline. + if (options.withMarkers || options.withTicks || options.withMemory) { + timelineStart = this._timeline.start(mapRecordingOptions("timeline", options)); + } + + if (options.withAllocations) { + if (this._memory.getState() === "detached") { + this._memory.attach(); + } + memoryStart = this._memory.startRecordingAllocations(extend(mapRecordingOptions("memory", options), { + drainAllocationsTimeout: DRAIN_ALLOCATIONS_TIMEOUT + })); + } + + let [profilerStartData, timelineStartData, memoryStartData] = yield promise.all([ + profilerStart, timelineStart, memoryStart + ]); + + let data = Object.create(null); + // Filter out start times that are not actually used (0 or undefined), and + // find the earliest time since all sources use same epoch. + let startTimes = [profilerStartData.currentTime, memoryStartData, timelineStartData].filter(Boolean); + data.startTime = Math.min(...startTimes); + data.position = profilerStartData.position; + data.generation = profilerStartData.generation; + data.totalSize = profilerStartData.totalSize; + + data.systemClient = this._systemClient; + data.systemHost = yield getSystemInfo(); + + let model = new PerformanceRecordingActor(this.conn, options, data); + this._recordings.push(model); + + events.emit(this, "recording-started", model); + return model; + }), + + /** + * Manually ends the recording session for the corresponding PerformanceRecording. + * + * @param PerformanceRecording model + * The corresponding PerformanceRecording that belongs to the recording session wished to stop. + * @return PerformanceRecording + * Returns the same model, populated with the profiling data. + */ + stopRecording: Task.async(function* (model) { + // If model isn't in the Recorder's internal store, + // then do nothing, like if this was a console.profileEnd + // from a different target. + if (this._recordings.indexOf(model) === -1) { + return model; + } + + // Flag the recording as no longer recording, so that `model.isRecording()` + // is false. Do this before we fetch all the data, and then subsequently + // the recording can be considered "completed". + let endTime = Date.now(); + events.emit(this, "recording-stopping", model); + + // Currently there are two ways profiles stop recording. Either manually in the + // performance tool, or via console.profileEnd. Once a recording is done, + // we want to deliver the model to the performance tool (either as a return + // from the PerformanceFront or via `console-profile-stop` event) and then + // remove it from the internal store. + // + // In the case where a console.profile is generated via the console (so the tools are + // open), we initialize the Performance tool so it can listen to those events. + this._recordings.splice(this._recordings.indexOf(model), 1); + + let startTime = model._startTime; + let profilerData = this._profiler.getProfile({ startTime }); + + // Only if there are no more sessions recording do we stop + // the underlying memory and timeline actors. If we're still recording, + // juse use Date.now() for the memory and timeline end times, as those + // are only used in tests. + if (!this.isRecording()) { + // Check to see if memory is recording, so we only stop recording + // if necessary (otherwise if the memory component is not attached, this will fail) + if (this._memory.isRecordingAllocations()) { + this._memory.stopRecordingAllocations(); + } + this._timeline.stop(); + } + + let recordingData = { + // Data available only at the end of a recording. + profile: profilerData.profile, + // End times for all the actors. + duration: profilerData.currentTime - startTime, + }; + + events.emit(this, "recording-stopped", model, recordingData); + return model; + }), + + /** + * Checks all currently stored recording handles and returns a boolean + * if there is a session currently being recorded. + * + * @return Boolean + */ + isRecording: function () { + return this._recordings.some(h => h.isRecording()); + }, + + /** + * Returns all current recordings. + */ + getRecordings: function () { + return this._recordings; + }, + + /** + * Sets how often the "profiler-status" event should be emitted. + * Used in tests. + */ + setProfilerStatusInterval: function (n) { + this._profiler.setProfilerStatusInterval(n); + }, + + /** + * Returns the configurations set on underlying components, used in tests. + * Returns an object with `probability`, `maxLogLength` for allocations, and + * `features`, `threadFilters`, `entries` and `interval` for profiler. + * + * @return {object} + */ + getConfiguration: function () { + let allocationSettings = Object.create(null); + + if (this._memory.getState() === "attached") { + allocationSettings = this._memory.getAllocationsSettings(); + } + + return extend({}, allocationSettings, this._profiler.getStartOptions()); + }, + + toString: () => "[object PerformanceRecorder]" +}); + +/** + * Creates an object of configurations based off of preferences for a PerformanceRecording. + */ +function getPerformanceRecordingPrefs() { + return { + withMarkers: true, + withMemory: Services.prefs.getBoolPref("devtools.performance.ui.enable-memory"), + withTicks: Services.prefs.getBoolPref("devtools.performance.ui.enable-framerate"), + withAllocations: Services.prefs.getBoolPref("devtools.performance.ui.enable-allocations"), + allocationsSampleProbability: +Services.prefs.getCharPref("devtools.performance.memory.sample-probability"), + allocationsMaxLogLength: Services.prefs.getIntPref("devtools.performance.memory.max-log-length") + }; +} diff --git a/devtools/server/performance/timeline.js b/devtools/server/performance/timeline.js new file mode 100644 index 000000000..3d7a90811 --- /dev/null +++ b/devtools/server/performance/timeline.js @@ -0,0 +1,356 @@ +/* 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"; + +/** + * Many Gecko operations (painting, reflows, restyle, ...) can be tracked + * in real time. A marker is a representation of one operation. A marker + * has a name, start and end timestamps. Markers are stored in docShells. + * + * This module exposes this tracking mechanism. To use with devtools' RDP, + * use devtools/server/actors/timeline.js directly. + * + * To start/stop recording markers: + * timeline.start() + * timeline.stop() + * timeline.isRecording() + * + * When markers are available, an event is emitted: + * timeline.on("markers", function(markers) {...}) + */ + +const { Ci, Cu } = require("chrome"); +const { Class } = require("sdk/core/heritage"); +// Be aggressive about lazy loading, as this will run on every +// toolbox startup +loader.lazyRequireGetter(this, "events", "sdk/event/core"); +loader.lazyRequireGetter(this, "Task", "devtools/shared/task", true); +loader.lazyRequireGetter(this, "Memory", "devtools/server/performance/memory", true); +loader.lazyRequireGetter(this, "Framerate", "devtools/server/performance/framerate", true); +loader.lazyRequireGetter(this, "StackFrameCache", "devtools/server/actors/utils/stack", true); +loader.lazyRequireGetter(this, "EventTarget", "sdk/event/target", true); + +// How often do we pull markers from the docShells, and therefore, how often do +// we send events to the front (knowing that when there are no markers in the +// docShell, no event is sent). +const DEFAULT_TIMELINE_DATA_PULL_TIMEOUT = 200; // ms + +/** + * The timeline actor pops and forwards timeline markers registered in docshells. + */ +var Timeline = exports.Timeline = Class({ + extends: EventTarget, + + /** + * Initializes this actor with the provided connection and tab actor. + */ + initialize: function (tabActor) { + this.tabActor = tabActor; + + this._isRecording = false; + this._stackFrames = null; + this._memory = null; + this._framerate = null; + + // Make sure to get markers from new windows as they become available + this._onWindowReady = this._onWindowReady.bind(this); + this._onGarbageCollection = this._onGarbageCollection.bind(this); + events.on(this.tabActor, "window-ready", this._onWindowReady); + }, + + /** + * Destroys this actor, stopping recording first. + */ + destroy: function () { + this.stop(); + + events.off(this.tabActor, "window-ready", this._onWindowReady); + this.tabActor = null; + }, + + /** + * Get the list of docShells in the currently attached tabActor. Note that we + * always list the docShells included in the real root docShell, even if the + * tabActor was switched to a child frame. This is because for now, paint + * markers are only recorded at parent frame level so switching the timeline + * to a child frame would hide all paint markers. + * See https://bugzilla.mozilla.org/show_bug.cgi?id=1050773#c14 + * @return {Array} + */ + get docShells() { + let originalDocShell; + let docShells = []; + + if (this.tabActor.isRootActor) { + originalDocShell = this.tabActor.docShell; + } else { + originalDocShell = this.tabActor.originalDocShell; + } + + if (!originalDocShell) { + return docShells; + } + + let docShellsEnum = originalDocShell.getDocShellEnumerator( + Ci.nsIDocShellTreeItem.typeAll, + Ci.nsIDocShell.ENUMERATE_FORWARDS + ); + + while (docShellsEnum.hasMoreElements()) { + let docShell = docShellsEnum.getNext(); + docShells.push(docShell.QueryInterface(Ci.nsIDocShell)); + } + + return docShells; + }, + + /** + * At regular intervals, pop the markers from the docshell, and forward + * markers, memory, tick and frames events, if any. + */ + _pullTimelineData: function () { + let docShells = this.docShells; + if (!this._isRecording || !docShells.length) { + return; + } + + let endTime = docShells[0].now(); + let markers = []; + + // Gather markers if requested. + if (this._withMarkers || this._withDocLoadingEvents) { + for (let docShell of docShells) { + for (let marker of docShell.popProfileTimelineMarkers()) { + markers.push(marker); + + // The docshell may return markers with stack traces attached. + // Here we transform the stack traces via the stack frame cache, + // which lets us preserve tail sharing when transferring the + // frames to the client. We must waive xrays here because Firefox + // doesn't understand that the Debugger.Frame object is safe to + // use from chrome. See Tutorial-Alloc-Log-Tree.md. + if (this._withFrames) { + if (marker.stack) { + marker.stack = this._stackFrames.addFrame(Cu.waiveXrays(marker.stack)); + } + if (marker.endStack) { + marker.endStack = this._stackFrames.addFrame(Cu.waiveXrays(marker.endStack)); + } + } + + // Emit some helper events for "DOMContentLoaded" and "Load" markers. + if (this._withDocLoadingEvents) { + if (marker.name == "document::DOMContentLoaded" || + marker.name == "document::Load") { + events.emit(this, "doc-loading", marker, endTime); + } + } + } + } + } + + // Emit markers if requested. + if (this._withMarkers && markers.length > 0) { + events.emit(this, "markers", markers, endTime); + } + + // Emit framerate data if requested. + if (this._withTicks) { + events.emit(this, "ticks", endTime, this._framerate.getPendingTicks()); + } + + // Emit memory data if requested. + if (this._withMemory) { + events.emit(this, "memory", endTime, this._memory.measure()); + } + + // Emit stack frames data if requested. + if (this._withFrames && this._withMarkers) { + let frames = this._stackFrames.makeEvent(); + if (frames) { + events.emit(this, "frames", endTime, frames); + } + } + + this._dataPullTimeout = setTimeout(() => { + this._pullTimelineData(); + }, DEFAULT_TIMELINE_DATA_PULL_TIMEOUT); + }, + + /** + * Are we recording profile markers currently? + */ + isRecording: function () { + return this._isRecording; + }, + + /** + * Start recording profile markers. + * + * @option {boolean} withMarkers + * Boolean indicating whether or not timeline markers are emitted + * once they're accumulated every `DEFAULT_TIMELINE_DATA_PULL_TIMEOUT` + * milliseconds. + * @option {boolean} withTicks + * Boolean indicating whether a `ticks` event is fired and a + * FramerateActor is created. + * @option {boolean} withMemory + * Boolean indiciating whether we want memory measurements sampled. + * @option {boolean} withFrames + * Boolean indicating whether or not stack frames should be handled + * from timeline markers. + * @option {boolean} withGCEvents + * Boolean indicating whether or not GC markers should be emitted. + * TODO: Remove these fake GC markers altogether in bug 1198127. + * @option {boolean} withDocLoadingEvents + * Boolean indicating whether or not DOMContentLoaded and Load + * marker events are emitted. + */ + start: Task.async(function* ({ + withMarkers, + withTicks, + withMemory, + withFrames, + withGCEvents, + withDocLoadingEvents, + }) { + let docShells = this.docShells; + if (!docShells.length) { + return -1; + } + let startTime = this._startTime = docShells[0].now(); + if (this._isRecording) { + return startTime; + } + + this._isRecording = true; + this._withMarkers = !!withMarkers; + this._withTicks = !!withTicks; + this._withMemory = !!withMemory; + this._withFrames = !!withFrames; + this._withGCEvents = !!withGCEvents; + this._withDocLoadingEvents = !!withDocLoadingEvents; + + if (this._withMarkers || this._withDocLoadingEvents) { + for (let docShell of docShells) { + docShell.recordProfileTimelineMarkers = true; + } + } + + if (this._withTicks) { + this._framerate = new Framerate(this.tabActor); + this._framerate.startRecording(); + } + + if (this._withMemory || this._withGCEvents) { + this._memory = new Memory(this.tabActor, this._stackFrames); + this._memory.attach(); + } + + if (this._withGCEvents) { + events.on(this._memory, "garbage-collection", this._onGarbageCollection); + } + + if (this._withFrames && this._withMarkers) { + this._stackFrames = new StackFrameCache(); + this._stackFrames.initFrames(); + } + + this._pullTimelineData(); + return startTime; + }), + + /** + * Stop recording profile markers. + */ + stop: Task.async(function* () { + let docShells = this.docShells; + if (!docShells.length) { + return -1; + } + let endTime = this._startTime = docShells[0].now(); + if (!this._isRecording) { + return endTime; + } + + if (this._withMarkers || this._withDocLoadingEvents) { + for (let docShell of docShells) { + docShell.recordProfileTimelineMarkers = false; + } + } + + if (this._withTicks) { + this._framerate.stopRecording(); + this._framerate.destroy(); + this._framerate = null; + } + + if (this._withMemory || this._withGCEvents) { + this._memory.detach(); + this._memory.destroy(); + } + + if (this._withGCEvents) { + events.off(this._memory, "garbage-collection", this._onGarbageCollection); + } + + if (this._withFrames && this._withMarkers) { + this._stackFrames = null; + } + + this._isRecording = false; + this._withMarkers = false; + this._withTicks = false; + this._withMemory = false; + this._withFrames = false; + this._withDocLoadingEvents = false; + this._withGCEvents = false; + + clearTimeout(this._dataPullTimeout); + + return endTime; + }), + + /** + * When a new window becomes available in the tabActor, start recording its + * markers if we were recording. + */ + _onWindowReady: function ({ window }) { + if (this._isRecording) { + let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + docShell.recordProfileTimelineMarkers = true; + } + }, + + /** + * Fired when the Memory component emits a `garbage-collection` event. Used to + * take the data and make it look like the rest of our markers. + * + * A GC "marker" here represents a full GC cycle, which may contain several incremental + * events within its `collection` array. The marker contains a `reason` field, indicating + * why there was a GC, and may contain a `nonincrementalReason` when SpiderMonkey could + * not incrementally collect garbage. + */ + _onGarbageCollection: function ({ collections, gcCycleNumber, reason, nonincrementalReason }) { + let docShells = this.docShells; + if (!this._isRecording || !docShells.length) { + return; + } + + let endTime = docShells[0].now(); + + events.emit(this, "markers", collections.map(({ startTimestamp: start, endTimestamp: end }) => { + return { + name: "GarbageCollection", + causeName: reason, + nonincrementalReason: nonincrementalReason, + cycle: gcCycleNumber, + start, + end, + }; + }), endTime); + }, +}); diff --git a/devtools/server/primitive.js b/devtools/server/primitive.js new file mode 100644 index 000000000..45346bc78 --- /dev/null +++ b/devtools/server/primitive.js @@ -0,0 +1,165 @@ +/* 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 { on, once, off, emit } = require("sdk/event/core"); +const { Class } = require("sdk/core/heritage"); + +const WebGLPrimitivesType = { + "POINTS": 0, + "LINES": 1, + "LINE_LOOP": 2, + "LINE_STRIP": 3, + "TRIANGLES": 4, + "TRIANGLE_STRIP": 5, + "TRIANGLE_FAN": 6 +}; + +/** + * A utility for monitoring WebGL primitive draws. Takes a `tabActor` + * and monitors primitive draws over time. + */ +const WebGLDrawArrays = "drawArrays"; +const WebGLDrawElements = "drawElements"; + +var WebGLPrimitiveCounter = exports.WebGLPrimitiveCounter = Class({ + initialize: function (tabActor) { + this.tabActor = tabActor; + }, + + destroy: function () { + this.stopRecording(); + }, + + /** + * Starts monitoring primitive draws, storing the primitives count per tick. + */ + resetCounts: function () { + this._tris = 0; + this._vertices = 0; + this._points = 0; + this._lines = 0; + this._startTime = this.tabActor.docShell.now(); + }, + + /** + * Stops monitoring primitive draws, returning the recorded values. + */ + getCounts: function () { + var result = { + tris: this._tris, + vertices: this._vertices, + points: this._points, + lines: this._lines + }; + + this._tris = 0; + this._vertices = 0; + this._points = 0; + this._lines = 0; + return result; + }, + + /** + * Handles WebGL draw primitive functions to catch primitive info. + */ + handleDrawPrimitive: function (functionCall) { + let { name, args } = functionCall.details; + + if (name === WebGLDrawArrays) { + this._processDrawArrays(args); + } else if (name === WebGLDrawElements) { + this._processDrawElements(args); + } + }, + + /** + * Processes WebGL drawArrays method to count primitve numbers + */ + _processDrawArrays: function (args) { + let mode = args[0]; + let count = args[2]; + + switch (mode) { + case WebGLPrimitivesType.POINTS: + this._vertices += count; + this._points += count; + break; + case WebGLPrimitivesType.LINES: + this._vertices += count; + this._lines += (count / 2); + break; + case WebGLPrimitivesType.LINE_LOOP: + this._vertices += count; + this._lines += count; + break; + case WebGLPrimitivesType.LINE_STRIP: + this._vertices += count; + this._lines += (count - 1); + break; + case WebGLPrimitivesType.TRIANGLES: + this._tris += (count / 3); + this._vertices += count; + break; + case WebGLPrimitivesType.TRIANGLE_STRIP: + this._tris += (count - 2); + this._vertices += count; + break; + case WebGLPrimitivesType.TRIANGLE_FAN: + this._tris += (count - 2); + this._vertices += count; + break; + default: + console.error("_processDrawArrays doesn't define this type."); + break; + } + }, + + /** + * Processes WebGL drawElements method to count primitve numbers + */ + _processDrawElements: function (args) { + let mode = args[0]; + let count = args[1]; + + switch (mode) { + case WebGLPrimitivesType.POINTS: + this._vertices += count; + this._points += count; + break; + case WebGLPrimitivesType.LINES: + this._vertices += count; + this._lines += (count / 2); + break; + case WebGLPrimitivesType.LINE_LOOP: + this._vertices += count; + this._lines += count; + break; + case WebGLPrimitivesType.LINE_STRIP: + this._vertices += count; + this._lines += (count - 1); + break; + case WebGLPrimitivesType.TRIANGLES: + let tris = count / 3; + let vertex = tris * 3; + + if (tris > 1) { + vertex = tris * 2; + } + this._tris += tris; + this._vertices += vertex; + break; + case WebGLPrimitivesType.TRIANGLE_STRIP: + this._tris += (count - 2); + this._vertices += count; + break; + case WebGLPrimitivesType.TRIANGLE_FAN: + this._tris += (count - 2); + this._vertices += count; + default: + console.error("_processDrawElements doesn't define this type."); + break; + } + } +}); diff --git a/devtools/server/service-worker-child.js b/devtools/server/service-worker-child.js new file mode 100644 index 000000000..e3a27ab10 --- /dev/null +++ b/devtools/server/service-worker-child.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +let { classes: Cc, interfaces: Ci, utils: Cu } = Components; +let swm = Cc["@mozilla.org/serviceworkers/manager;1"]. + getService(Ci.nsIServiceWorkerManager); + +addMessageListener("serviceWorkerRegistration:start", message => { + let { data } = message; + let array = swm.getAllRegistrations(); + + // Find the service worker registration with the desired scope. + for (let i = 0; i < array.length; i++) { + let registration = + array.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo); + // XXX: In some rare cases, `registration.activeWorker` can be null for a + // brief moment (e.g. while the service worker is first installing, or if + // there was an unhandled exception during install that will cause the + // registration to be removed). We can't do much about it here, simply + // ignore these cases. + if (registration.scope === data.scope && registration.activeWorker) { + // Briefly attaching a debugger to the active service worker will cause + // it to start running. + registration.activeWorker.attachDebugger(); + registration.activeWorker.detachDebugger(); + return; + } + } +}); diff --git a/devtools/server/shims/moz.build b/devtools/server/shims/moz.build new file mode 100644 index 000000000..cebaa79b4 --- /dev/null +++ b/devtools/server/shims/moz.build @@ -0,0 +1,18 @@ +# -*- 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/. + +DIRS += [ + 'toolkit', +] + +# Unlike most DevTools build files, this file does not use DevToolsModules +# because these files are here for add-on compatibility, and so they must be +# installed to previously defined locations. + +# Extra compatibility layer for old path after relocation in bug 1270173 +FINAL_TARGET_FILES.chrome.devtools.modules.devtools.server += [ + 'protocol.js', +] diff --git a/devtools/server/shims/protocol.js b/devtools/server/shims/protocol.js new file mode 100644 index 000000000..34c6f13bb --- /dev/null +++ b/devtools/server/shims/protocol.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * This file only exists to support add-ons which import this module at a + * specific path. + */ + +const { Cu } = require("chrome"); +const Services = require("Services"); + +const WARNING_PREF = "devtools.migration.warnings"; +if (Services.prefs.getBoolPref(WARNING_PREF)) { + const { Deprecated } = Cu.import("resource://gre/modules/Deprecated.jsm", {}); + Deprecated.warning("This path to protocol.js is deprecated. Please use " + + "require(\"devtools/shared/protocol\") to load this " + + "module.", + "https://bugzil.la/1270173"); +} + +module.exports = require("devtools/shared/protocol"); diff --git a/devtools/server/shims/toolkit/dbg-server.jsm b/devtools/server/shims/toolkit/dbg-server.jsm new file mode 100644 index 000000000..3114e72dc --- /dev/null +++ b/devtools/server/shims/toolkit/dbg-server.jsm @@ -0,0 +1,37 @@ +/* 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 file only exists to support add-ons which import this module at a + * specific path. + */ + +const Cu = Components.utils; + +const { Services } = Cu.import("resource://gre/modules/Services.jsm", {}); + +const WARNING_PREF = "devtools.migration.warnings"; +if (Services.prefs.getBoolPref(WARNING_PREF)) { + const { Deprecated } = Cu.import("resource://gre/modules/Deprecated.jsm", {}); + Deprecated.warning("dbg-server.jsm is deprecated. Please use " + + "require(\"devtools/server/main\") to load this " + + "module.", + "https://bugzil.la/912121"); +} + +this.EXPORTED_SYMBOLS = [ + "DebuggerServer", + "ActorPool", + "OriginalLocation", +]; + +const { require } = + Cu.import("resource://devtools/shared/Loader.jsm", {}); +const module = require("devtools/server/main"); + +for (let symbol of this.EXPORTED_SYMBOLS) { + this[symbol] = module[symbol]; +} diff --git a/devtools/server/shims/toolkit/moz.build b/devtools/server/shims/toolkit/moz.build new file mode 100644 index 000000000..4bfd07f04 --- /dev/null +++ b/devtools/server/shims/toolkit/moz.build @@ -0,0 +1,17 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# Unlike most DevTools build files, this file does not use DevToolsModules +# because these files are here for add-on compatibility, and so they must be +# installed to previously defined locations. + +# These shims for legacy paths expect to be installed as if they were part of +# /toolkit modules. Disable any DIST_SUBDIR from parent files here. +DIST_SUBDIR = '' + +EXTRA_JS_MODULES.devtools += [ + 'dbg-server.jsm', +] diff --git a/devtools/server/tests/browser/.eslintrc.js b/devtools/server/tests/browser/.eslintrc.js new file mode 100644 index 000000000..c5b919ce3 --- /dev/null +++ b/devtools/server/tests/browser/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the common devtools mochitest eslintrc config. + "extends": "../../../.eslintrc.xpcshell.js" +}; diff --git a/devtools/server/tests/browser/animation.html b/devtools/server/tests/browser/animation.html new file mode 100644 index 000000000..d10a9873d --- /dev/null +++ b/devtools/server/tests/browser/animation.html @@ -0,0 +1,170 @@ +<!DOCTYPE html> +<style> + .not-animated { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: #eee; + } + + .simple-animation { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: red; + + animation: move 200s infinite; + } + + .multiple-animations { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: #eee; + + animation: move 200s infinite , glow 100s 5; + animation-timing-function: ease-out; + animation-direction: reverse; + animation-fill-mode: both; + } + + .transition { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: #f06; + + transition: width 500s ease-out; + } + .transition.get-round { + width: 200px; + } + + .long-animation { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: gold; + + animation: move 100s; + } + + .short-animation { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: purple; + + animation: move 1s; + } + + .delayed-animation { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: rebeccapurple; + + animation: move 200s 5s infinite; + } + + .delayed-transition { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: black; + + transition: width 500s 3s; + } + .delayed-transition.get-round { + width: 200px; + } + + .delayed-multiple-animations { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: green; + + animation: move .5s 1s 10, glow 1s .75s 30; + } + + .multiple-animations-2 { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: blue; + + animation: move .5s, glow 100s 2s infinite, grow 300s 1s 100; + } + + .all-transitions { + position: absolute; + top: 0; + right: 0; + width: 50px; + height: 50px; + background: blue; + transition: all .2s; + } + .all-transitions.expand { + width: 200px; + height: 100px; + } + + @keyframes move { + 100% { + transform: translateY(100px); + } + } + + @keyframes glow { + 100% { + background: yellow; + } + } + + @keyframes grow { + 100% { + width: 100px; + } + } +</style> +<div class="not-animated"></div> +<div class="simple-animation"></div> +<div class="multiple-animations"></div> +<div class="transition"></div> +<div class="long-animation"></div> +<div class="short-animation"></div> +<div class="delayed-animation"></div> +<div class="delayed-transition"></div> +<div class="delayed-multiple-animations"></div> +<div class="multiple-animations-2"></div> +<div class="all-transitions"></div> +<script type="text/javascript"> + // Get the transitions started when the page loads + var players; + addEventListener("load", function() { + document.querySelector(".transition").classList.add("get-round"); + document.querySelector(".delayed-transition").classList.add("get-round"); + }); +</script> diff --git a/devtools/server/tests/browser/browser.ini b/devtools/server/tests/browser/browser.ini new file mode 100644 index 000000000..c05933230 --- /dev/null +++ b/devtools/server/tests/browser/browser.ini @@ -0,0 +1,97 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + head.js + animation.html + doc_allocations.html + doc_force_cc.html + doc_force_gc.html + doc_innerHTML.html + doc_perf.html + navigate-first.html + navigate-second.html + storage-dynamic-windows.html + storage-listings.html + storage-unsecured-iframe.html + storage-updates.html + storage-secured-iframe.html + stylesheets-nested-iframes.html + timeline-iframe-child.html + timeline-iframe-parent.html + director-script-target.html + storage-helpers.js + !/devtools/server/tests/mochitest/hello-actor.js + +[browser_animation_emitMutations.js] +[browser_animation_getFrames.js] +[browser_animation_getProperties.js] +[browser_animation_getMultipleStates.js] +[browser_animation_getPlayers.js] +[browser_animation_getStateAfterFinished.js] +[browser_animation_getSubTreeAnimations.js] +[browser_animation_keepFinished.js] +[browser_animation_playerState.js] +[browser_animation_playPauseIframe.js] +[browser_animation_playPauseSeveral.js] +[browser_animation_reconstructState.js] +[browser_animation_refreshTransitions.js] +[browser_animation_setCurrentTime.js] +[browser_animation_setPlaybackRate.js] +[browser_animation_simple.js] +[browser_animation_updatedState.js] +skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S +[browser_canvasframe_helper_01.js] +skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S +[browser_canvasframe_helper_02.js] +[browser_canvasframe_helper_03.js] +skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S +[browser_canvasframe_helper_04.js] +skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S +[browser_canvasframe_helper_05.js] +skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S +[browser_canvasframe_helper_06.js] +skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S +[browser_markers-cycle-collection.js] +[browser_markers-docloading-01.js] +[browser_markers-docloading-02.js] +[browser_markers-docloading-03.js] +[browser_markers-gc.js] +[browser_markers-minor-gc.js] +[browser_markers-parse-html.js] +[browser_markers-styles.js] +[browser_markers-timestamp.js] +[browser_navigateEvents.js] +skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S +[browser_perf-allocation-data.js] +skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S +[browser_perf-profiler-01.js] +[browser_perf-profiler-02.js] +skip-if = true # Needs to be updated for async actor destruction +[browser_perf-profiler-03.js] +skip-if = true # Needs to be updated for async actor destruction +[browser_perf-realtime-markers.js] +[browser_perf-recording-actor-01.js] +skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S +[browser_perf-recording-actor-02.js] +skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S +[browser_perf-samples-01.js] +[browser_perf-samples-02.js] +#[browser_perf-front-profiler-01.js] bug 1077464 +#[browser_perf-front-profiler-05.js] bug 1077464 +#[browser_perf-front-profiler-06.js] +[browser_storage_dynamic_windows.js] +[browser_storage_listings.js] +[browser_storage_updates.js] +[browser_stylesheets_getTextEmpty.js] +[browser_stylesheets_nested-iframes.js] +[browser_timeline.js] +[browser_timeline_actors.js] +[browser_timeline_iframes.js] +[browser_directorscript_actors_exports.js] +skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S +[browser_directorscript_actors_error_events.js] +skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S +[browser_directorscript_actors.js] +skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S +[browser_register_actor.js] diff --git a/devtools/server/tests/browser/browser_animation_emitMutations.js b/devtools/server/tests/browser/browser_animation_emitMutations.js new file mode 100644 index 000000000..4783a8209 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_emitMutations.js @@ -0,0 +1,62 @@ +/* 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 AnimationsActor emits events about changed animations on a +// node after getAnimationPlayersForNode was called on that node. + +add_task(function* () { + let {client, walker, animations} = + yield initAnimationsFrontForUrl(MAIN_DOMAIN + "animation.html"); + + info("Retrieve a non-animated node"); + let node = yield walker.querySelector(walker.rootNode, ".not-animated"); + + info("Retrieve the animation player for the node"); + let players = yield animations.getAnimationPlayersForNode(node); + is(players.length, 0, "The node has no animation players"); + + info("Listen for new animations"); + let onMutations = once(animations, "mutations"); + + info("Add a couple of animation on the node"); + yield node.modifyAttributes([ + {attributeName: "class", newValue: "multiple-animations"} + ]); + let changes = yield onMutations; + + ok(true, "The mutations event was emitted"); + is(changes.length, 2, "There are 2 changes in the mutation event"); + ok(changes.every(({type}) => type === "added"), "Both changes are additions"); + + let names = changes.map(c => c.player.initialState.name).sort(); + is(names[0], "glow", "The animation 'glow' was added"); + is(names[1], "move", "The animation 'move' was added"); + + info("Store the 2 new players for comparing later"); + let p1 = changes[0].player; + let p2 = changes[1].player; + + info("Listen for removed animations"); + onMutations = once(animations, "mutations"); + + info("Remove the animation css class on the node"); + yield node.modifyAttributes([ + {attributeName: "class", newValue: "not-animated"} + ]); + + changes = yield onMutations; + + ok(true, "The mutations event was emitted"); + is(changes.length, 2, "There are 2 changes in the mutation event"); + ok(changes.every(({type}) => type === "removed"), "Both are removals"); + ok(changes[0].player === p1 || changes[0].player === p2, + "The first removed player was one of the previously added players"); + ok(changes[1].player === p1 || changes[1].player === p2, + "The second removed player was one of the previously added players"); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_animation_getFrames.js b/devtools/server/tests/browser/browser_animation_getFrames.js new file mode 100644 index 000000000..25ccfae3b --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_getFrames.js @@ -0,0 +1,32 @@ +/* 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";
+
+// Check that the AnimationPlayerActor exposes a getFrames method that returns
+// the list of keyframes in the animation.
+
+const URL = MAIN_DOMAIN + "animation.html";
+
+add_task(function* () {
+ let {client, walker, animations} =
+ yield initAnimationsFrontForUrl(MAIN_DOMAIN + "animation.html");
+
+ info("Get the test node and its animation front");
+ let node = yield walker.querySelector(walker.rootNode, ".simple-animation");
+ let [player] = yield animations.getAnimationPlayersForNode(node);
+
+ ok(player.getFrames, "The front has the getFrames method");
+
+ let frames = yield player.getFrames();
+ is(frames.length, 2, "The correct number of keyframes was retrieved");
+ ok(frames[0].transform, "Frame 0 has the transform property");
+ ok(frames[1].transform, "Frame 1 has the transform property");
+ // Note that we don't really test the content of the frame object here on
+ // purpose. This object comes straight out of the web animations API
+ // unmodified.
+
+ yield client.close();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_animation_getMultipleStates.js b/devtools/server/tests/browser/browser_animation_getMultipleStates.js new file mode 100644 index 000000000..4436695b0 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_getMultipleStates.js @@ -0,0 +1,55 @@ +/* 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"; + +add_task(function* setup() { + yield SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount", 1]] + }); +}); + +// Check that the duration, iterationCount and delay are retrieved correctly for +// multiple animations. + +add_task(function* () { + let {client, walker, animations} = + yield initAnimationsFrontForUrl(MAIN_DOMAIN + "animation.html"); + + yield playerHasAnInitialState(walker, animations); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); + +function* playerHasAnInitialState(walker, animations) { + let state = yield getAnimationStateForNode(walker, animations, + ".delayed-multiple-animations", 0); + + ok(state.duration, 50000, + "The duration of the first animation is correct"); + ok(state.iterationCount, 10, + "The iterationCount of the first animation is correct"); + ok(state.delay, 1000, + "The delay of the first animation is correct"); + + state = yield getAnimationStateForNode(walker, animations, + ".delayed-multiple-animations", 1); + + ok(state.duration, 100000, + "The duration of the secon animation is correct"); + ok(state.iterationCount, 30, + "The iterationCount of the secon animation is correct"); + ok(state.delay, 750, + "The delay of the secon animation is correct"); +} + +function* getAnimationStateForNode(walker, animations, selector, playerIndex) { + let node = yield walker.querySelector(walker.rootNode, selector); + let players = yield animations.getAnimationPlayersForNode(node); + let player = players[playerIndex]; + yield player.ready(); + let state = yield player.getCurrentState(); + return state; +} diff --git a/devtools/server/tests/browser/browser_animation_getPlayers.js b/devtools/server/tests/browser/browser_animation_getPlayers.js new file mode 100644 index 000000000..a99a4dc4e --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_getPlayers.js @@ -0,0 +1,63 @@ +/* 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"; + +// Check the output of getAnimationPlayersForNode + +add_task(function* () { + let {client, walker, animations} = + yield initAnimationsFrontForUrl(MAIN_DOMAIN + "animation.html"); + + yield theRightNumberOfPlayersIsReturned(walker, animations); + yield playersCanBePausedAndResumed(walker, animations); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); + +function* theRightNumberOfPlayersIsReturned(walker, animations) { + let node = yield walker.querySelector(walker.rootNode, ".not-animated"); + let players = yield animations.getAnimationPlayersForNode(node); + is(players.length, 0, + "0 players were returned for the unanimated node"); + + node = yield walker.querySelector(walker.rootNode, ".simple-animation"); + players = yield animations.getAnimationPlayersForNode(node); + is(players.length, 1, + "One animation player was returned"); + + node = yield walker.querySelector(walker.rootNode, ".multiple-animations"); + players = yield animations.getAnimationPlayersForNode(node); + is(players.length, 2, + "Two animation players were returned"); + + node = yield walker.querySelector(walker.rootNode, ".transition"); + players = yield animations.getAnimationPlayersForNode(node); + is(players.length, 1, + "One animation player was returned for the transitioned node"); +} + +function* playersCanBePausedAndResumed(walker, animations) { + let node = yield walker.querySelector(walker.rootNode, ".simple-animation"); + let [player] = yield animations.getAnimationPlayersForNode(node); + yield player.ready(); + + ok(player.initialState, + "The player has an initialState"); + ok(player.getCurrentState, + "The player has the getCurrentState method"); + is(player.initialState.playState, "running", + "The animation is currently running"); + + yield player.pause(); + let state = yield player.getCurrentState(); + is(state.playState, "paused", + "The animation is now paused"); + + yield player.play(); + state = yield player.getCurrentState(); + is(state.playState, "running", + "The animation is now running again"); +} diff --git a/devtools/server/tests/browser/browser_animation_getProperties.js b/devtools/server/tests/browser/browser_animation_getProperties.js new file mode 100644 index 000000000..e95db544c --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_getProperties.js @@ -0,0 +1,36 @@ +/* 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";
+
+// Check that the AnimationPlayerActor exposes a getProperties method that
+// returns the list of animated properties in the animation.
+
+const URL = MAIN_DOMAIN + "animation.html";
+
+add_task(function* () {
+ let {client, walker, animations} = yield initAnimationsFrontForUrl(URL);
+
+ info("Get the test node and its animation front");
+ let node = yield walker.querySelector(walker.rootNode, ".simple-animation");
+ let [player] = yield animations.getAnimationPlayersForNode(node);
+
+ ok(player.getProperties, "The front has the getProperties method");
+
+ let properties = yield player.getProperties();
+ is(properties.length, 1, "The correct number of properties was retrieved");
+
+ let propertyObject = properties[0];
+ is(propertyObject.name, "transform", "Property 0 is transform");
+
+ is(propertyObject.values.length, 2,
+ "The correct number of property values was retrieved");
+
+ // Note that we don't really test the content of the frame object here on
+ // purpose. This object comes straight out of the web animations API
+ // unmodified.
+
+ yield client.close();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_animation_getStateAfterFinished.js b/devtools/server/tests/browser/browser_animation_getStateAfterFinished.js new file mode 100644 index 000000000..dd33237c1 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_getStateAfterFinished.js @@ -0,0 +1,55 @@ +/* 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"; + +// Check that the right duration/iterationCount/delay are retrieved even when +// the node has multiple animations and one of them already ended before getting +// the player objects. +// See devtools/server/actors/animation.js |getPlayerIndex| for more +// information. + +add_task(function* () { + let {client, walker, animations} = + yield initAnimationsFrontForUrl(MAIN_DOMAIN + "animation.html"); + + info("Retrieve a non animated node"); + let node = yield walker.querySelector(walker.rootNode, ".not-animated"); + + info("Apply the multiple-animations-2 class to start the animations"); + yield node.modifyAttributes([ + {attributeName: "class", newValue: "multiple-animations-2"} + ]); + + info("Get the list of players, by the time this executes, the first, " + + "short, animation should have ended."); + let players = yield animations.getAnimationPlayersForNode(node); + if (players.length === 3) { + info("The short animation hasn't ended yet, wait for a bit."); + // The animation lasts for 500ms, so 1000ms should do it. + yield new Promise(resolve => setTimeout(resolve, 1000)); + + info("And get the list again"); + players = yield animations.getAnimationPlayersForNode(node); + } + + is(players.length, 2, "2 animations remain on the node"); + + is(players[0].state.duration, 100000, + "The duration of the first animation is correct"); + is(players[0].state.delay, 2000, + "The delay of the first animation is correct"); + is(players[0].state.iterationCount, null, + "The iterationCount of the first animation is correct"); + + is(players[1].state.duration, 300000, + "The duration of the second animation is correct"); + is(players[1].state.delay, 1000, + "The delay of the second animation is correct"); + is(players[1].state.iterationCount, 100, + "The iterationCount of the second animation is correct"); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_animation_getSubTreeAnimations.js b/devtools/server/tests/browser/browser_animation_getSubTreeAnimations.js new file mode 100644 index 000000000..50782d6de --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_getSubTreeAnimations.js @@ -0,0 +1,38 @@ +/* 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"; + +// Check that the AnimationsActor can retrieve all animations inside a node's +// subtree (but not going into iframes). + +const URL = MAIN_DOMAIN + "animation.html"; + +add_task(function* () { + info("Creating a test document with 2 iframes containing animated nodes"); + + let {client, walker, animations} = yield initAnimationsFrontForUrl( + "data:text/html;charset=utf-8," + + "<iframe id='iframe' src='" + URL + "'></iframe>"); + + info("Try retrieving all animations from the root doc's <body> node"); + let rootBody = yield walker.querySelector(walker.rootNode, "body"); + let players = yield animations.getAnimationPlayersForNode(rootBody); + is(players.length, 0, "The node has no animation players"); + + info("Retrieve all animations from the iframe's <body> node"); + let iframe = yield walker.querySelector(walker.rootNode, "#iframe"); + let {nodes} = yield walker.children(iframe); + let frameBody = yield walker.querySelector(nodes[0], "body"); + players = yield animations.getAnimationPlayersForNode(frameBody); + + // Testing for a hard-coded number of animations here would intermittently + // fail depending on how fast or slow the test is (indeed, the test page + // contains short transitions, and delayed animations). So just make sure we + // at least have the infinitely running animations. + ok(players.length >= 4, "All subtree animations were retrieved"); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_animation_keepFinished.js b/devtools/server/tests/browser/browser_animation_keepFinished.js new file mode 100644 index 000000000..a3240a5e0 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_keepFinished.js @@ -0,0 +1,54 @@ +/* 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 AnimationsActor doesn't report finished animations as removed. +// Indeed, animations that only have the "finished" playState can be modified +// still, so we want the AnimationsActor to preserve the corresponding +// AnimationPlayerActor. + +add_task(function* () { + let {client, walker, animations} = + yield initAnimationsFrontForUrl(MAIN_DOMAIN + "animation.html"); + + info("Retrieve a non-animated node"); + let node = yield walker.querySelector(walker.rootNode, ".not-animated"); + + info("Retrieve the animation player for the node"); + let players = yield animations.getAnimationPlayersForNode(node); + is(players.length, 0, "The node has no animation players"); + + info("Listen for new animations"); + let reportedMutations = []; + function onMutations(mutations) { + reportedMutations = [...reportedMutations, ...mutations]; + } + animations.on("mutations", onMutations); + + info("Add a short animation on the node"); + yield node.modifyAttributes([ + {attributeName: "class", newValue: "short-animation"} + ]); + + info("Wait for longer than the animation's duration"); + yield wait(2000); + + players = yield animations.getAnimationPlayersForNode(node); + is(players.length, 0, "The added animation is surely finished"); + + is(reportedMutations.length, 1, "Only one mutation was reported"); + is(reportedMutations[0].type, "added", "The mutation was an addition"); + + animations.off("mutations", onMutations); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); + +function wait(ms) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} diff --git a/devtools/server/tests/browser/browser_animation_playPauseIframe.js b/devtools/server/tests/browser/browser_animation_playPauseIframe.js new file mode 100644 index 000000000..52320b84e --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_playPauseIframe.js @@ -0,0 +1,51 @@ +/* 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"; + +// Check that the AnimationsActor can pause/play all animations even those +// within iframes. + +const URL = MAIN_DOMAIN + "animation.html"; + +add_task(function* () { + info("Creating a test document with 2 iframes containing animated nodes"); + + let {client, walker, animations} = yield initAnimationsFrontForUrl( + "data:text/html;charset=utf-8," + + "<iframe id='i1' src='" + URL + "'></iframe>" + + "<iframe id='i2' src='" + URL + "'></iframe>"); + + info("Getting the 2 iframe container nodes and animated nodes in them"); + let nodeInFrame1 = yield getNodeInFrame(walker, "#i1", ".simple-animation"); + let nodeInFrame2 = yield getNodeInFrame(walker, "#i2", ".simple-animation"); + + info("Pause all animations in the test document"); + yield animations.pauseAll(); + yield checkState(animations, nodeInFrame1, "paused"); + yield checkState(animations, nodeInFrame2, "paused"); + + info("Play all animations in the test document"); + yield animations.playAll(); + yield checkState(animations, nodeInFrame1, "running"); + yield checkState(animations, nodeInFrame2, "running"); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); + +function* checkState(animations, nodeFront, playState) { + info("Getting the AnimationPlayerFront for the test node"); + let [player] = yield animations.getAnimationPlayersForNode(nodeFront); + yield player.ready; + let state = yield player.getCurrentState(); + is(state.playState, playState, + "The playState of the test node is " + playState); +} + +function* getNodeInFrame(walker, frameSelector, nodeSelector) { + let iframe = yield walker.querySelector(walker.rootNode, frameSelector); + let {nodes} = yield walker.children(iframe); + return walker.querySelector(nodes[0], nodeSelector); +} diff --git a/devtools/server/tests/browser/browser_animation_playPauseSeveral.js b/devtools/server/tests/browser/browser_animation_playPauseSeveral.js new file mode 100644 index 000000000..9c52b5f57 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_playPauseSeveral.js @@ -0,0 +1,92 @@ +/* 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"; + +// Check that the AnimationsActor can pause/play all animations at once, and +// check that it can also pause/play a given list of animations at once. + +// List of selectors that match "all" animated nodes in the test page. +// This list misses a bunch of animated nodes on purpose. Only the ones that +// have infinite animations are listed. This is done to avoid intermittents +// caused when finite animations are already done playing by the time the test +// runs. +const ALL_ANIMATED_NODES = [".simple-animation", ".multiple-animations", + ".delayed-animation"]; + +add_task(function* () { + let {client, walker, animations} = + yield initAnimationsFrontForUrl(MAIN_DOMAIN + "animation.html"); + + info("Pause all animations in the test document"); + yield animations.pauseAll(); + yield checkStates(walker, animations, ALL_ANIMATED_NODES, "paused"); + + info("Play all animations in the test document"); + yield animations.playAll(); + yield checkStates(walker, animations, ALL_ANIMATED_NODES, "running"); + + info("Pause all animations in the test document using toggleAll"); + yield animations.toggleAll(); + yield checkStates(walker, animations, ALL_ANIMATED_NODES, "paused"); + + info("Play all animations in the test document using toggleAll"); + yield animations.toggleAll(); + yield checkStates(walker, animations, ALL_ANIMATED_NODES, "running"); + + info("Play all animations from multiple animated node using toggleSeveral"); + let players = yield getPlayersFor(walker, animations, + [".multiple-animations"]); + is(players.length, 2, "Node has 2 animation players"); + yield animations.toggleSeveral(players, false); + let state1 = yield players[0].getCurrentState(); + is(state1.playState, "running", + "The playState of the first player is running"); + let state2 = yield players[1].getCurrentState(); + is(state2.playState, "running", + "The playState of the second player is running"); + + info("Pause one animation from a multiple animated node using toggleSeveral"); + yield animations.toggleSeveral([players[0]], true); + state1 = yield players[0].getCurrentState(); + is(state1.playState, "paused", "The playState of the first player is paused"); + state2 = yield players[1].getCurrentState(); + is(state2.playState, "running", + "The playState of the second player is running"); + + info("Play the same animation"); + yield animations.toggleSeveral([players[0]], false); + state1 = yield players[0].getCurrentState(); + is(state1.playState, "running", + "The playState of the first player is running"); + state2 = yield players[1].getCurrentState(); + is(state2.playState, "running", + "The playState of the second player is running"); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); + +function* checkStates(walker, animations, selectors, playState) { + info("Checking the playState of all the nodes that have infinite running " + + "animations"); + + for (let selector of selectors) { + info("Getting the AnimationPlayerFront for node " + selector); + let [player] = yield getPlayersFor(walker, animations, selector); + yield player.ready(); + yield checkPlayState(player, selector, playState); + } +} + +function* getPlayersFor(walker, animations, selector) { + let node = yield walker.querySelector(walker.rootNode, selector); + return animations.getAnimationPlayersForNode(node); +} + +function* checkPlayState(player, selector, expectedState) { + let state = yield player.getCurrentState(); + is(state.playState, expectedState, + "The playState of node " + selector + " is " + expectedState); +} diff --git a/devtools/server/tests/browser/browser_animation_playerState.js b/devtools/server/tests/browser/browser_animation_playerState.js new file mode 100644 index 000000000..ac5842e39 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_playerState.js @@ -0,0 +1,123 @@ +/* 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"; + +// Check the animation player's initial state + +add_task(function* () { + let {client, walker, animations} = + yield initAnimationsFrontForUrl(MAIN_DOMAIN + "animation.html"); + + yield playerHasAnInitialState(walker, animations); + yield playerStateIsCorrect(walker, animations); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); + +function* playerHasAnInitialState(walker, animations) { + let node = yield walker.querySelector(walker.rootNode, ".simple-animation"); + let [player] = yield animations.getAnimationPlayersForNode(node); + + ok(player.initialState, "The player front has an initial state"); + ok("startTime" in player.initialState, "Player's state has startTime"); + ok("currentTime" in player.initialState, "Player's state has currentTime"); + ok("playState" in player.initialState, "Player's state has playState"); + ok("playbackRate" in player.initialState, "Player's state has playbackRate"); + ok("name" in player.initialState, "Player's state has name"); + ok("duration" in player.initialState, "Player's state has duration"); + ok("delay" in player.initialState, "Player's state has delay"); + ok("iterationCount" in player.initialState, + "Player's state has iterationCount"); + ok("fill" in player.initialState, "Player's state has fill"); + ok("easing" in player.initialState, "Player's state has easing"); + ok("direction" in player.initialState, "Player's state has direction"); + ok("isRunningOnCompositor" in player.initialState, + "Player's state has isRunningOnCompositor"); + ok("type" in player.initialState, "Player's state has type"); + ok("documentCurrentTime" in player.initialState, + "Player's state has documentCurrentTime"); +} + +function* playerStateIsCorrect(walker, animations) { + info("Checking the state of the simple animation"); + + let player = yield getAnimationPlayerForNode(walker, animations, + ".simple-animation", 0); + let state = yield player.getCurrentState(); + is(state.name, "move", "Name is correct"); + is(state.duration, 200000, "Duration is correct"); + // null = infinite count + is(state.iterationCount, null, "Iteration count is correct"); + is(state.fill, "none", "Fill is correct"); + is(state.easing, "linear", "Easing is correct"); + is(state.direction, "normal", "Direction is correct"); + is(state.playState, "running", "PlayState is correct"); + is(state.playbackRate, 1, "PlaybackRate is correct"); + is(state.type, "cssanimation", "Type is correct"); + + info("Checking the state of the transition"); + + player = + yield getAnimationPlayerForNode(walker, animations, ".transition", 0); + state = yield player.getCurrentState(); + is(state.name, "width", "Transition name matches transition property"); + is(state.duration, 500000, "Transition duration is correct"); + // transitions run only once + is(state.iterationCount, 1, "Transition iteration count is correct"); + is(state.fill, "backwards", "Transition fill is correct"); + is(state.easing, "linear", "Transition easing is correct"); + is(state.direction, "normal", "Transition direction is correct"); + is(state.playState, "running", "Transition playState is correct"); + is(state.playbackRate, 1, "Transition playbackRate is correct"); + is(state.type, "csstransition", "Transition type is correct"); + // chech easing in keyframe + let keyframes = yield player.getFrames(); + is(keyframes.length, 2, "Transition length of keyframe is correct"); + is(keyframes[0].easing, + "ease-out", "Transition kerframes's easing is correct"); + + info("Checking the state of one of multiple animations on a node"); + + // Checking the 2nd player + player = yield getAnimationPlayerForNode(walker, animations, + ".multiple-animations", 1); + state = yield player.getCurrentState(); + is(state.name, "glow", "The 2nd animation's name is correct"); + is(state.duration, 100000, "The 2nd animation's duration is correct"); + is(state.iterationCount, 5, "The 2nd animation's iteration count is correct"); + is(state.fill, "both", "The 2nd animation's fill is correct"); + is(state.easing, "linear", "The 2nd animation's easing is correct"); + is(state.direction, "reverse", "The 2nd animation's direction is correct"); + is(state.playState, "running", "The 2nd animation's playState is correct"); + is(state.playbackRate, 1, "The 2nd animation's playbackRate is correct"); + // chech easing in keyframe + keyframes = yield player.getFrames(); + is(keyframes.length, 2, "The 2nd animation's length of keyframe is correct"); + is(keyframes[0].easing, + "ease-out", "The 2nd animation's easing of kerframes is correct"); + + info("Checking the state of an animation with delay"); + + player = yield getAnimationPlayerForNode(walker, animations, + ".delayed-animation", 0); + state = yield player.getCurrentState(); + is(state.delay, 5000, "The animation delay is correct"); + + info("Checking the state of an transition with delay"); + + player = yield getAnimationPlayerForNode(walker, animations, + ".delayed-transition", 0); + state = yield player.getCurrentState(); + is(state.delay, 3000, "The transition delay is correct"); +} + +function* getAnimationPlayerForNode(walker, animations, nodeSelector, index) { + let node = yield walker.querySelector(walker.rootNode, nodeSelector); + let players = yield animations.getAnimationPlayersForNode(node); + let player = players[index]; + yield player.ready(); + return player; +} diff --git a/devtools/server/tests/browser/browser_animation_reconstructState.js b/devtools/server/tests/browser/browser_animation_reconstructState.js new file mode 100644 index 000000000..cd3007b86 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_reconstructState.js @@ -0,0 +1,38 @@ +/* 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"; + +// Check that, even though the AnimationPlayerActor only sends the bits of its +// state that change, the front reconstructs the whole state everytime. + +add_task(function* () { + let {client, walker, animations} = + yield initAnimationsFrontForUrl(MAIN_DOMAIN + "animation.html"); + + yield playerHasCompleteStateAtAllTimes(walker, animations); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); + +function* playerHasCompleteStateAtAllTimes(walker, animations) { + let node = yield walker.querySelector(walker.rootNode, ".simple-animation"); + let [player] = yield animations.getAnimationPlayersForNode(node); + yield player.ready(); + + // Get the list of state key names from the initialstate. + let keys = Object.keys(player.initialState); + + // Get the state over and over again and check that the object returned + // contains all keys. + // Normally, only the currentTime will have changed in between 2 calls. + for (let i = 0; i < 10; i++) { + yield player.refreshState(); + keys.forEach(key => { + ok(typeof player.state[key] !== "undefined", + "The state retrieved has key " + key); + }); + } +} diff --git a/devtools/server/tests/browser/browser_animation_refreshTransitions.js b/devtools/server/tests/browser/browser_animation_refreshTransitions.js new file mode 100644 index 000000000..4cec0df69 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_refreshTransitions.js @@ -0,0 +1,77 @@ +/* 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"; + +// When a transition finishes, no "removed" event is sent because it may still +// be used, but when it restarts again (transitions back), then a new +// AnimationPlayerFront should be sent, and the old one should be removed. + +add_task(function* () { + let {client, walker, animations} = + yield initAnimationsFrontForUrl(MAIN_DOMAIN + "animation.html"); + + info("Retrieve the test node"); + let node = yield walker.querySelector(walker.rootNode, ".all-transitions"); + + info("Retrieve the animation players for the node"); + let players = yield animations.getAnimationPlayersForNode(node); + is(players.length, 0, "The node has no animation players yet"); + + info("Play a transition by adding the expand class, wait for mutations"); + let onMutations = expectMutationEvents(animations, 2); + let cpow = content.document.querySelector(".all-transitions"); + cpow.classList.add("expand"); + let reportedMutations = yield onMutations; + + is(reportedMutations.length, 2, "2 mutation events were received"); + is(reportedMutations[0].type, "added", "The first event was 'added'"); + is(reportedMutations[1].type, "added", "The second event was 'added'"); + + info("Wait for the transitions to be finished"); + yield waitForEnd(reportedMutations[0].player); + yield waitForEnd(reportedMutations[1].player); + + info("Play the transition back by removing the class, wait for mutations"); + onMutations = expectMutationEvents(animations, 4); + cpow.classList.remove("expand"); + reportedMutations = yield onMutations; + + is(reportedMutations.length, 4, "4 new mutation events were received"); + is(reportedMutations.filter(m => m.type === "removed").length, 2, + "2 'removed' events were sent (for the old transitions)"); + is(reportedMutations.filter(m => m.type === "added").length, 2, + "2 'added' events were sent (for the new transitions)"); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); + +function expectMutationEvents(animationsFront, nbOfEvents) { + return new Promise(resolve => { + let reportedMutations = []; + function onMutations(mutations) { + reportedMutations = [...reportedMutations, ...mutations]; + info("Received " + reportedMutations.length + " mutation events, " + + "expecting " + nbOfEvents); + if (reportedMutations.length === nbOfEvents) { + animationsFront.off("mutations", onMutations); + resolve(reportedMutations); + } + } + + info("Start listening for mutation events from the AnimationsFront"); + animationsFront.on("mutations", onMutations); + }); +} + +function* waitForEnd(animationFront) { + let playState; + while (playState !== "finished") { + let state = yield animationFront.getCurrentState(); + playState = state.playState; + info("Wait for transition " + animationFront.state.name + + " to finish, playState=" + playState); + } +} diff --git a/devtools/server/tests/browser/browser_animation_setCurrentTime.js b/devtools/server/tests/browser/browser_animation_setCurrentTime.js new file mode 100644 index 000000000..16dbaa544 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_setCurrentTime.js @@ -0,0 +1,74 @@ +/* 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"; + +// Check that a player's currentTime can be changed and that the AnimationsActor +// allows changing many players' currentTimes at once. + +add_task(function* () { + let {client, walker, animations} = + yield initAnimationsFrontForUrl(MAIN_DOMAIN + "animation.html"); + + yield testSetCurrentTime(walker, animations); + yield testSetCurrentTimes(walker, animations); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); + +function* testSetCurrentTime(walker, animations) { + info("Retrieve an animated node"); + let node = yield walker.querySelector(walker.rootNode, ".simple-animation"); + + info("Retrieve the animation player for the node"); + let [player] = yield animations.getAnimationPlayersForNode(node); + + ok(player.setCurrentTime, "Player has the setCurrentTime method"); + + info("Check that the setCurrentTime method can be called"); + // Note that we don't check that it sets the animation to the right time here, + // this is too prone to intermittent failures, we'll do this later after + // pausing the animation. Here we merely test that the method doesn't fail. + yield player.setCurrentTime(player.initialState.currentTime + 1000); + + info("Pause the animation so we can really test if setCurrentTime works"); + yield player.pause(); + let pausedState = yield player.getCurrentState(); + + info("Set the current time to currentTime + 5s"); + yield player.setCurrentTime(pausedState.currentTime + 5000); + + let updatedState1 = yield player.getCurrentState(); + is(Math.round(updatedState1.currentTime - pausedState.currentTime), 5000, + "The currentTime was updated to +5s"); + + info("Set the current time to currentTime - 2s"); + yield player.setCurrentTime(updatedState1.currentTime - 2000); + let updatedState2 = yield player.getCurrentState(); + is(Math.round(updatedState2.currentTime - updatedState1.currentTime), -2000, + "The currentTime was updated to -2s"); +} + +function* testSetCurrentTimes(walker, animations) { + ok(animations.setCurrentTimes, "The AnimationsActor has the right method"); + + info("Retrieve multiple animated node and its animation players"); + + let nodeMulti = yield walker.querySelector(walker.rootNode, + ".multiple-animations"); + let players = (yield animations.getAnimationPlayersForNode(nodeMulti)); + + ok(players.length > 1, "Node has more than 1 animation player"); + + info("Try to set multiple current times at once"); + yield animations.setCurrentTimes(players, 500, true); + + info("Get the states of players and verify their correctness"); + for (let i = 0; i < players.length; i++) { + let state = yield players[i].getCurrentState(); + is(state.playState, "paused", `Player ${i + 1} is paused`); + is(state.currentTime, 500, `Player ${i + 1} has the right currentTime`); + } +} diff --git a/devtools/server/tests/browser/browser_animation_setPlaybackRate.js b/devtools/server/tests/browser/browser_animation_setPlaybackRate.js new file mode 100644 index 000000000..b6d41b51e --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_setPlaybackRate.js @@ -0,0 +1,51 @@ +/* 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"; + +// Check that a player's playbackRate can be changed, and that multiple players +// can have their rates changed at the same time. + +add_task(function* () { + let {client, walker, animations} = + yield initAnimationsFrontForUrl(MAIN_DOMAIN + "animation.html"); + + info("Retrieve an animated node"); + let node = yield walker.querySelector(walker.rootNode, ".simple-animation"); + + info("Retrieve the animation player for the node"); + let [player] = yield animations.getAnimationPlayersForNode(node); + + ok(player.setPlaybackRate, "Player has the setPlaybackRate method"); + + info("Change the rate to 10"); + yield player.setPlaybackRate(10); + + info("Query the state again"); + let state = yield player.getCurrentState(); + is(state.playbackRate, 10, "The playbackRate was updated"); + + info("Change the rate back to 1"); + yield player.setPlaybackRate(1); + + info("Query the state again"); + state = yield player.getCurrentState(); + is(state.playbackRate, 1, "The playbackRate was changed back"); + + info("Retrieve several animation players and set their rates"); + node = yield walker.querySelector(walker.rootNode, "body"); + let players = yield animations.getAnimationPlayersForNode(node); + + info("Change all animations in <body> to .5 rate"); + yield animations.setPlaybackRates(players, .5); + + info("Query their states and check they are correct"); + for (let player of players) { + let state = yield player.getCurrentState(); + is(state.playbackRate, .5, "The playbackRate was updated"); + } + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_animation_simple.js b/devtools/server/tests/browser/browser_animation_simple.js new file mode 100644 index 000000000..52daf0084 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_simple.js @@ -0,0 +1,35 @@ +/* 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"; + +// Simple checks for the AnimationsActor + +add_task(function* () { + let {client, walker, animations} = yield initAnimationsFrontForUrl( + "data:text/html;charset=utf-8,<title>test</title><div></div>"); + + ok(animations, "The AnimationsFront was created"); + ok(animations.getAnimationPlayersForNode, + "The getAnimationPlayersForNode method exists"); + ok(animations.toggleAll, "The toggleAll method exists"); + ok(animations.playAll, "The playAll method exists"); + ok(animations.pauseAll, "The pauseAll method exists"); + + let didThrow = false; + try { + yield animations.getAnimationPlayersForNode(null); + } catch (e) { + didThrow = true; + } + ok(didThrow, "An exception was thrown for a missing NodeActor"); + + let invalidNode = yield walker.querySelector(walker.rootNode, "title"); + let players = yield animations.getAnimationPlayersForNode(invalidNode); + ok(Array.isArray(players), "An array of players was returned"); + is(players.length, 0, "0 players have been returned for the invalid node"); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_animation_updatedState.js b/devtools/server/tests/browser/browser_animation_updatedState.js new file mode 100644 index 000000000..17d68e9e5 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_updatedState.js @@ -0,0 +1,55 @@ +/* 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"; + +// Check the animation player's updated state + +add_task(function* () { + let {client, walker, animations} = + yield initAnimationsFrontForUrl(MAIN_DOMAIN + "animation.html"); + + yield playStateIsUpdatedDynamically(walker, animations); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); + +function* playStateIsUpdatedDynamically(walker, animations) { + info("Getting the test node (which runs a very long animation)"); + // The animation lasts for 100s, to avoid intermittents. + let node = yield walker.querySelector(walker.rootNode, ".long-animation"); + + info("Getting the animation player front for this node"); + let [player] = yield animations.getAnimationPlayersForNode(node); + yield player.ready(); + + let state = yield player.getCurrentState(); + is(state.playState, "running", + "The playState is running while the animation is running"); + + info("Change the animation's currentTime to be near the end and wait for " + + "it to finish"); + let onFinished = waitForAnimationPlayState(player, "finished"); + // Set the currentTime to 98s, knowing that the animation lasts for 100s. + yield player.setCurrentTime(98 * 1000); + state = yield onFinished; + is(state.playState, "finished", + "The animation has ended and the state has been updated"); + ok(state.currentTime > player.initialState.currentTime, + "The currentTime has been updated"); +} + +function* waitForAnimationPlayState(player, playState) { + let state = {}; + while (state.playState !== playState) { + state = yield player.getCurrentState(); + yield wait(500); + } + return state; +} + +function wait(ms) { + return new Promise(r => setTimeout(r, ms)); +} diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_01.js b/devtools/server/tests/browser/browser_canvasframe_helper_01.js new file mode 100644 index 000000000..7fd943197 --- /dev/null +++ b/devtools/server/tests/browser/browser_canvasframe_helper_01.js @@ -0,0 +1,90 @@ +/* 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"; + +// Simple CanvasFrameAnonymousContentHelper tests. + +// This makes sure the 'domnode' protocol actor type is known when importing +// highlighter. +require("devtools/server/actors/inspector"); +const {HighlighterEnvironment} = require("devtools/server/actors/highlighters"); + +const { + CanvasFrameAnonymousContentHelper +} = require("devtools/server/actors/highlighters/utils/markup"); + +const TEST_URL = "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test"; + +add_task(function* () { + let browser = yield addTab(TEST_URL); + let doc = browser.contentDocument; + + let nodeBuilder = () => { + let root = doc.createElement("div"); + let child = doc.createElement("div"); + child.style = "width:200px;height:200px;background:red;"; + child.id = "child-element"; + child.className = "child-element"; + child.textContent = "test element"; + root.appendChild(child); + return root; + }; + + info("Building the helper"); + let env = new HighlighterEnvironment(); + env.initFromWindow(doc.defaultView); + let helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder); + + ok(helper.content instanceof AnonymousContent, + "The helper owns the AnonymousContent object"); + ok(helper.getTextContentForElement, + "The helper has the getTextContentForElement method"); + ok(helper.setTextContentForElement, + "The helper has the setTextContentForElement method"); + ok(helper.setAttributeForElement, + "The helper has the setAttributeForElement method"); + ok(helper.getAttributeForElement, + "The helper has the getAttributeForElement method"); + ok(helper.removeAttributeForElement, + "The helper has the removeAttributeForElement method"); + ok(helper.addEventListenerForElement, + "The helper has the addEventListenerForElement method"); + ok(helper.removeEventListenerForElement, + "The helper has the removeEventListenerForElement method"); + ok(helper.getElement, + "The helper has the getElement method"); + ok(helper.scaleRootElement, + "The helper has the scaleRootElement method"); + + is(helper.getTextContentForElement("child-element"), "test element", + "The text content was retrieve correctly"); + is(helper.getAttributeForElement("child-element", "id"), "child-element", + "The ID attribute was retrieve correctly"); + is(helper.getAttributeForElement("child-element", "class"), "child-element", + "The class attribute was retrieve correctly"); + + let el = helper.getElement("child-element"); + ok(el, "The DOMNode-like element was created"); + + is(el.getTextContent(), "test element", + "The text content was retrieve correctly"); + is(el.getAttribute("id"), "child-element", + "The ID attribute was retrieve correctly"); + is(el.getAttribute("class"), "child-element", + "The class attribute was retrieve correctly"); + + info("Destroying the helper"); + helper.destroy(); + env.destroy(); + + ok(!helper.getTextContentForElement("child-element"), + "No text content was retrieved after the helper was destroyed"); + ok(!helper.getAttributeForElement("child-element", "id"), + "No ID attribute was retrieved after the helper was destroyed"); + ok(!helper.getAttributeForElement("child-element", "class"), + "No class attribute was retrieved after the helper was destroyed"); + + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_02.js b/devtools/server/tests/browser/browser_canvasframe_helper_02.js new file mode 100644 index 000000000..90400c50a --- /dev/null +++ b/devtools/server/tests/browser/browser_canvasframe_helper_02.js @@ -0,0 +1,48 @@ +/* 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 CanvasFrameAnonymousContentHelper does not insert content in +// XUL windows. + +// This makes sure the 'domnode' protocol actor type is known when importing +// highlighter. +require("devtools/server/actors/inspector"); + +const {HighlighterEnvironment} = require("devtools/server/actors/highlighters"); + +const { + CanvasFrameAnonymousContentHelper +} = require("devtools/server/actors/highlighters/utils/markup"); + +add_task(function* () { + let browser = yield addTab("about:preferences"); + let doc = browser.contentDocument; + + let nodeBuilder = () => { + let root = doc.createElement("div"); + let child = doc.createElement("div"); + child.style = "width:200px;height:200px;background:red;"; + child.id = "child-element"; + child.className = "child-element"; + child.textContent = "test element"; + root.appendChild(child); + return root; + }; + + info("Building the helper"); + let env = new HighlighterEnvironment(); + env.initFromWindow(doc.defaultView); + let helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder); + + ok(!helper.content, "The AnonymousContent was not inserted in the window"); + ok(!helper.getTextContentForElement("child-element"), + "No text content is returned"); + + env.destroy(); + helper.destroy(); + + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_03.js b/devtools/server/tests/browser/browser_canvasframe_helper_03.js new file mode 100644 index 000000000..85e27c7de --- /dev/null +++ b/devtools/server/tests/browser/browser_canvasframe_helper_03.js @@ -0,0 +1,102 @@ +/* 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 the CanvasFrameAnonymousContentHelper event handling mechanism. + +// This makes sure the 'domnode' protocol actor type is known when importing +// highlighter. +require("devtools/server/actors/inspector"); + +const {HighlighterEnvironment} = require("devtools/server/actors/highlighters"); + +const { + CanvasFrameAnonymousContentHelper +} = require("devtools/server/actors/highlighters/utils/markup"); + +const TEST_URL = "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test"; + +add_task(function* () { + let browser = yield addTab(TEST_URL); + let doc = browser.contentDocument; + + let nodeBuilder = () => { + let root = doc.createElement("div"); + let child = doc.createElement("div"); + child.style = "pointer-events:auto;width:200px;height:200px;background:red;"; + child.id = "child-element"; + child.className = "child-element"; + root.appendChild(child); + return root; + }; + + info("Building the helper"); + let env = new HighlighterEnvironment(); + env.initFromWindow(doc.defaultView); + let helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder); + + let el = helper.getElement("child-element"); + + info("Adding an event listener on the inserted element"); + let mouseDownHandled = 0; + function onMouseDown(e, id) { + is(id, "child-element", "The mousedown event was triggered on the element"); + ok(!e.originalTarget, "The originalTarget property isn't available"); + mouseDownHandled++; + } + el.addEventListener("mousedown", onMouseDown); + + info("Synthesizing an event on the inserted element"); + let onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + yield onDocMouseDown; + + is(mouseDownHandled, 1, "The mousedown event was handled once on the element"); + + info("Synthesizing an event somewhere else"); + onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(400, 400, doc.defaultView); + yield onDocMouseDown; + + is(mouseDownHandled, 1, "The mousedown event was not handled on the element"); + + info("Removing the event listener"); + el.removeEventListener("mousedown", onMouseDown); + + info("Synthesizing another event after the listener has been removed"); + // Using a document event listener to know when the event has been synthesized. + onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + yield onDocMouseDown; + + is(mouseDownHandled, 1, + "The mousedown event hasn't been handled after the listener was removed"); + + info("Adding again the event listener"); + el.addEventListener("mousedown", onMouseDown); + + info("Destroying the helper"); + env.destroy(); + helper.destroy(); + + info("Synthesizing another event after the helper has been destroyed"); + // Using a document event listener to know when the event has been synthesized. + onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + yield onDocMouseDown; + + is(mouseDownHandled, 1, + "The mousedown event hasn't been handled after the helper was destroyed"); + + gBrowser.removeCurrentTab(); +}); + +function synthesizeMouseDown(x, y, win) { + // We need to make sure the inserted anonymous content can be targeted by the + // event right after having been inserted, and so we need to force a sync + // reflow. + let forceReflow = win.document.documentElement.offsetWidth; + EventUtils.synthesizeMouseAtPoint(x, y, {type: "mousedown"}, win); +} diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_04.js b/devtools/server/tests/browser/browser_canvasframe_helper_04.js new file mode 100644 index 000000000..d038f84a0 --- /dev/null +++ b/devtools/server/tests/browser/browser_canvasframe_helper_04.js @@ -0,0 +1,98 @@ +/* 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 the CanvasFrameAnonymousContentHelper re-inserts the content when the +// page reloads. + +// This makes sure the 'domnode' protocol actor type is known when importing +// highlighter. +require("devtools/server/actors/inspector"); +const events = require("sdk/event/core"); + +const {HighlighterEnvironment} = require("devtools/server/actors/highlighters"); + +const { + CanvasFrameAnonymousContentHelper +} = require("devtools/server/actors/highlighters/utils/markup"); + +const TEST_URL_1 = "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test 1"; +const TEST_URL_2 = "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test 2"; + +add_task(function* () { + let browser = yield addTab(TEST_URL_2); + let doc = browser.contentDocument; + + let nodeBuilder = () => { + let root = doc.createElement("div"); + let child = doc.createElement("div"); + child.style = "pointer-events:auto;width:200px;height:200px;background:red;"; + child.id = "child-element"; + child.className = "child-element"; + child.textContent = "test content"; + root.appendChild(child); + return root; + }; + + info("Building the helper"); + let env = new HighlighterEnvironment(); + env.initFromWindow(doc.defaultView); + let helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder); + + info("Get an element from the helper"); + let el = helper.getElement("child-element"); + + info("Try to access the element"); + is(el.getAttribute("class"), "child-element", + "The attribute is correct before navigation"); + is(el.getTextContent(), "test content", + "The text content is correct before navigation"); + + info("Add an event listener on the element"); + let mouseDownHandled = 0; + function onMouseDown(e, id) { + is(id, "child-element", "The mousedown event was triggered on the element"); + mouseDownHandled++; + } + el.addEventListener("mousedown", onMouseDown); + + info("Synthesizing an event on the element"); + let onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + yield onDocMouseDown; + is(mouseDownHandled, 1, "The mousedown event was handled once before navigation"); + + info("Navigating to a new page"); + let loaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + content.location = TEST_URL_2; + yield loaded; + doc = gBrowser.selectedBrowser.contentWindow.document; + + info("Try to access the element again"); + is(el.getAttribute("class"), "child-element", + "The attribute is correct after navigation"); + is(el.getTextContent(), "test content", + "The text content is correct after navigation"); + + info("Synthesizing an event on the element again"); + onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + yield onDocMouseDown; + is(mouseDownHandled, 1, "The mousedown event was not handled after navigation"); + + info("Destroying the helper"); + env.destroy(); + helper.destroy(); + + gBrowser.removeCurrentTab(); +}); + +function synthesizeMouseDown(x, y, win) { + // We need to make sure the inserted anonymous content can be targeted by the + // event right after having been inserted, and so we need to force a sync + // reflow. + let forceReflow = win.document.documentElement.offsetWidth; + EventUtils.synthesizeMouseAtPoint(x, y, {type: "mousedown"}, win); +} diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_05.js b/devtools/server/tests/browser/browser_canvasframe_helper_05.js new file mode 100644 index 000000000..94fb66914 --- /dev/null +++ b/devtools/server/tests/browser/browser_canvasframe_helper_05.js @@ -0,0 +1,112 @@ +/* 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 some edge cases of the CanvasFrameAnonymousContentHelper event handling +// mechanism. + +// This makes sure the 'domnode' protocol actor type is known when importing +// highlighter. +require("devtools/server/actors/inspector"); + +const {HighlighterEnvironment} = require("devtools/server/actors/highlighters"); + +const { + CanvasFrameAnonymousContentHelper +} = require("devtools/server/actors/highlighters/utils/markup"); + +const TEST_URL = "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test"; + +add_task(function* () { + let browser = yield addTab(TEST_URL); + let doc = browser.contentDocument; + + let nodeBuilder = () => { + let root = doc.createElement("div"); + + let parent = doc.createElement("div"); + parent.style = "pointer-events:auto;width:300px;height:300px;background:yellow;"; + parent.id = "parent-element"; + root.appendChild(parent); + + let child = doc.createElement("div"); + child.style = "pointer-events:auto;width:200px;height:200px;background:red;"; + child.id = "child-element"; + parent.appendChild(child); + + return root; + }; + + info("Building the helper"); + let env = new HighlighterEnvironment(); + env.initFromWindow(doc.defaultView); + let helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder); + + info("Getting the parent and child elements"); + let parentEl = helper.getElement("parent-element"); + let childEl = helper.getElement("child-element"); + + info("Adding an event listener on both elements"); + let mouseDownHandled = []; + function onMouseDown(e, id) { + mouseDownHandled.push(id); + } + parentEl.addEventListener("mousedown", onMouseDown); + childEl.addEventListener("mousedown", onMouseDown); + + info("Synthesizing an event on the child element"); + let onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + yield onDocMouseDown; + + is(mouseDownHandled.length, 2, "The mousedown event was handled twice"); + is(mouseDownHandled[0], "child-element", + "The mousedown event was handled on the child element"); + is(mouseDownHandled[1], "parent-element", + "The mousedown event was handled on the parent element"); + + info("Synthesizing an event on the parent, outside of the child element"); + mouseDownHandled = []; + onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(250, 250, doc.defaultView); + yield onDocMouseDown; + + is(mouseDownHandled.length, 1, "The mousedown event was handled only once"); + is(mouseDownHandled[0], "parent-element", + "The mousedown event was handled on the parent element"); + + info("Removing the event listener"); + parentEl.removeEventListener("mousedown", onMouseDown); + childEl.removeEventListener("mousedown", onMouseDown); + + info("Adding an event listener on the parent element only"); + mouseDownHandled = []; + parentEl.addEventListener("mousedown", onMouseDown); + + info("Synthesizing an event on the child element"); + onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + yield onDocMouseDown; + + is(mouseDownHandled.length, 1, "The mousedown event was handled once"); + is(mouseDownHandled[0], "parent-element", + "The mousedown event did bubble to the parent element"); + + info("Removing the parent listener"); + parentEl.removeEventListener("mousedown", onMouseDown); + + env.destroy(); + helper.destroy(); + + gBrowser.removeCurrentTab(); +}); + +function synthesizeMouseDown(x, y, win) { + // We need to make sure the inserted anonymous content can be targeted by the + // event right after having been inserted, and so we need to force a sync + // reflow. + let forceReflow = win.document.documentElement.offsetWidth; + EventUtils.synthesizeMouseAtPoint(x, y, {type: "mousedown"}, win); +} diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_06.js b/devtools/server/tests/browser/browser_canvasframe_helper_06.js new file mode 100644 index 000000000..2b137fe26 --- /dev/null +++ b/devtools/server/tests/browser/browser_canvasframe_helper_06.js @@ -0,0 +1,100 @@ +/* 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 support for event propagation stop in the +// CanvasFrameAnonymousContentHelper event handling mechanism. + +// This makes sure the 'domnode' protocol actor type is known when importing +// highlighter. +require("devtools/server/actors/inspector"); + +const {HighlighterEnvironment} = require("devtools/server/actors/highlighters"); + +const { + CanvasFrameAnonymousContentHelper +} = require("devtools/server/actors/highlighters/utils/markup"); + +const TEST_URL = "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test"; + +add_task(function* () { + let browser = yield addTab(TEST_URL); + let doc = browser.contentDocument; + + let nodeBuilder = () => { + let root = doc.createElement("div"); + + let parent = doc.createElement("div"); + parent.style = "pointer-events:auto;width:300px;height:300px;background:yellow;"; + parent.id = "parent-element"; + root.appendChild(parent); + + let child = doc.createElement("div"); + child.style = "pointer-events:auto;width:200px;height:200px;background:red;"; + child.id = "child-element"; + parent.appendChild(child); + + return root; + }; + + info("Building the helper"); + let env = new HighlighterEnvironment(); + env.initFromWindow(doc.defaultView); + let helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder); + + info("Getting the parent and child elements"); + let parentEl = helper.getElement("parent-element"); + let childEl = helper.getElement("child-element"); + + info("Adding an event listener on both elements"); + let mouseDownHandled = []; + + function onParentMouseDown(e, id) { + mouseDownHandled.push(id); + } + parentEl.addEventListener("mousedown", onParentMouseDown); + + function onChildMouseDown(e, id) { + mouseDownHandled.push(id); + e.stopPropagation(); + } + childEl.addEventListener("mousedown", onChildMouseDown); + + info("Synthesizing an event on the child element"); + let onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + yield onDocMouseDown; + + is(mouseDownHandled.length, 1, "The mousedown event was handled only once"); + is(mouseDownHandled[0], "child-element", + "The mousedown event was handled on the child element"); + + info("Synthesizing an event on the parent, outside of the child element"); + mouseDownHandled = []; + onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(250, 250, doc.defaultView); + yield onDocMouseDown; + + is(mouseDownHandled.length, 1, "The mousedown event was handled only once"); + is(mouseDownHandled[0], "parent-element", + "The mousedown event was handled on the parent element"); + + info("Removing the event listener"); + parentEl.removeEventListener("mousedown", onParentMouseDown); + childEl.removeEventListener("mousedown", onChildMouseDown); + + env.destroy(); + helper.destroy(); + + gBrowser.removeCurrentTab(); +}); + +function synthesizeMouseDown(x, y, win) { + // We need to make sure the inserted anonymous content can be targeted by the + // event right after having been inserted, and so we need to force a sync + // reflow. + let forceReflow = win.document.documentElement.offsetWidth; + EventUtils.synthesizeMouseAtPoint(x, y, {type: "mousedown"}, win); +} diff --git a/devtools/server/tests/browser/browser_directorscript_actors.js b/devtools/server/tests/browser/browser_directorscript_actors.js new file mode 100644 index 000000000..bdfc8f8f1 --- /dev/null +++ b/devtools/server/tests/browser/browser_directorscript_actors.js @@ -0,0 +1,159 @@ +/* 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 {DirectorManagerFront} = require("devtools/shared/fronts/director-manager"); +const {DirectorRegistry} = require("devtools/server/actors/director-registry"); + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "director-script-target.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + + DirectorRegistry.clear(); + let directorManager = DirectorManagerFront(client, form); + + yield testDirectorScriptAttachEventAttributes(directorManager); + yield testDirectorScriptMessagePort(directorManager); + yield testDirectorScriptWindowEval(directorManager); + yield testDirectorScriptUnloadOnDetach(directorManager); + + yield client.close(); + gBrowser.removeCurrentTab(); + DirectorRegistry.clear(); +}); + +function* testDirectorScriptAttachEventAttributes(directorManager) { + let attachEvent = yield installAndEnableDirectorScript(directorManager, { + scriptId: "testDirectorScript_attachEventAttributes", + scriptCode: "(" + (function () { + exports.attach = function () {}; + }).toString() + ")();", + scriptOptions: { + attachMethod: "attach" + } + }); + + let { directorScriptId, url } = attachEvent; + + is(directorScriptId, "testDirectorScript_attachEventAttributes", + "attach event should contains directorScriptId"); + is(url, MAIN_DOMAIN + "director-script-target.html"); +} + +function* testDirectorScriptMessagePort(directorManager) { + let { port } = yield installAndEnableDirectorScript(directorManager, { + scriptId: "testDirectorScript_MessagePort", + scriptCode: "(" + (function () { + exports.attach = function ({port}) { + port.onmessage = function (evt) { + // echo messages + evt.source.postMessage(evt.data); + }; + }; + }).toString() + ")();", + scriptOptions: { + attachMethod: "attach" + } + }); + + ok(port && port.postMessage, "testDirector_MessagePort port received"); + + // exchange messages over the MessagePort + let waitForMessagePortEvent = once(port, "message"); + // needs to explicit start the port + port.start(); + + var msg = { k1: "v1", k2: [1, 2, 3] }; + port.postMessage(msg); + + var reply = yield waitForMessagePortEvent; + + is(JSON.stringify(reply.data), JSON.stringify(msg), "echo reply received on the MessagePortClient"); +} + +function* testDirectorScriptWindowEval(directorManager) { + let { port } = yield installAndEnableDirectorScript(directorManager, { + scriptId: "testDirectorScript_WindowEval", + scriptCode: "(" + (function () { + exports.attach = function ({window, port}) { + var onpageloaded = function () { + var globalVarValue = window.eval("globalAccessibleVar;"); + port.postMessage(globalVarValue); + }; + + if (window.document && window.document.readyState === "complete") { + onpageloaded(); + } else { + window.addEventListener("load", onpageloaded, false); + } + }; + }).toString() + ")();", + scriptOptions: { + attachMethod: "attach" + } + }); + + ok(port, "testDirectorScript_WindowEval port received"); + + // exchange messages over the MessagePort + let waitForMessagePortEvent = once(port, "message"); + // needs to explicit start the port + port.start(); + + var portEvent = yield waitForMessagePortEvent; + + ok(portEvent.data !== "unsecure-eval", "window.eval should be wrapped and safe"); + is(portEvent.data, "global-value", "globalAccessibleVar should be accessible through window.eval"); +} + +function* testDirectorScriptUnloadOnDetach(directorManager) { + let { port } = yield installAndEnableDirectorScript(directorManager, { + scriptId: "testDirectorScript_unloadOnDetach", + scriptCode: "(" + (function () { + exports.attach = function ({port, onUnload}) { + onUnload(function () { + port.postMessage("ONUNLOAD"); + }); + }; + }).toString() + ")();", + scriptOptions: { + attachMethod: "attach" + } + }); + + ok(port, "testDirectorScript_unloadOnDetach port received"); + port.start(); + + let waitForDetach = once(directorManager, "director-script-detach"); + let waitForMessage = once(port, "message"); + + directorManager.disableByScriptIds(["testDirectorScript_unloadOnDetach"], {reload: false}); + + let { directorScriptId } = yield waitForDetach; + is(directorScriptId, "testDirectorScript_unloadOnDetach", + "detach event should contains directorScriptId"); + + let portEvent = yield waitForMessage; + is(portEvent.data, "ONUNLOAD", "director-script's exports.onUnload called on detach"); +} + +function* installAndEnableDirectorScript(directorManager, directorScriptDef) { + let { scriptId } = directorScriptDef; + + DirectorRegistry.install(scriptId, directorScriptDef); + + let waitForAttach = once(directorManager, "director-script-attach"); + let waitForError = once(directorManager, "director-script-error"); + + directorManager.enableByScriptIds([scriptId], {reload: false}); + + let attachOrErrorEvent = yield Promise.race([waitForAttach, waitForError]); + + return attachOrErrorEvent; +} diff --git a/devtools/server/tests/browser/browser_directorscript_actors_error_events.js b/devtools/server/tests/browser/browser_directorscript_actors_error_events.js new file mode 100644 index 000000000..0afe16388 --- /dev/null +++ b/devtools/server/tests/browser/browser_directorscript_actors_error_events.js @@ -0,0 +1,132 @@ +/* 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 {DirectorManagerFront} = require("devtools/shared/fronts/director-manager"); +const {DirectorRegistry} = require("devtools/server/actors/director-registry"); + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "director-script-target.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + + DirectorRegistry.clear(); + let directorManager = DirectorManagerFront(client, form); + + yield testErrorOnRequire(directorManager); + yield testErrorOnEvaluate(directorManager); + yield testErrorOnAttach(directorManager); + yield testErrorOnDetach(directorManager); + + yield client.close(); + gBrowser.removeCurrentTab(); + DirectorRegistry.clear(); +}); + +function* testErrorOnRequire(directorManager) { + // director script require method should raise a "not implemented" exception + let errorOnRequire = yield installAndEnableDirectorScript(directorManager, { + scriptId: "testDirectorScript_errorOnRequire", + scriptCode: "(" + (function () { + // this director script should generate an error event + // because require raise a "not implemented" exception + require("fake_module"); + }).toString() + ")();", + scriptOptions: {} + }); + + assertIsDirectorScriptError(errorOnRequire); + + let { message } = errorOnRequire; + is(message, "Error: NOT IMPLEMENTED", "error.message contains the expected error message"); +} + +function* testErrorOnEvaluate(directorManager) { + // director scripts should send an error events if the director script raise an exception on + // evaluation + let errorOnEvaluate = yield installAndEnableDirectorScript(directorManager, { + scriptId: "testDirectorScript_errorOnEvaluate", + scriptCode: "(" + (function () { + // this will raise an exception evaluating + // the director script + raise.an_error.during.content_script.load(); + }).toString() + ")();", + scriptOptions: {} + }); + assertIsDirectorScriptError(errorOnEvaluate); +} + +function* testErrorOnAttach(directorManager) { + // director scripts should send an error events if the director script raise an exception on + // evaluation + let errorOnAttach = yield installAndEnableDirectorScript(directorManager, { + scriptId: "testDirectorScript_errorOnAttach", + scriptCode: "(" + (function () { + // this will raise an exception on evaluating + // the director script + module.exports = function () { + raise.an_error.during.content_script.load(); + }; + }).toString() + ")();", + scriptOptions: {} + }); + assertIsDirectorScriptError(errorOnAttach); +} + +function* testErrorOnDetach(directorManager) { + // director scripts should send an error events if the director script raise an exception on + // evaluation + let attach = yield installAndEnableDirectorScript(directorManager, { + scriptId: "testDirectorScript_errorOnDetach", + scriptCode: "(" + (function () { + module.exports = function ({onUnload}) { + // this will raise an exception on unload the director script + onUnload(function () { + raise_an_error_onunload(); + }); + }; + }).toString() + ")();", + scriptOptions: {} + }); + + let waitForDetach = once(directorManager, "director-script-detach"); + let waitForError = once(directorManager, "director-script-error"); + + directorManager.disableByScriptIds(["testDirectorScript_errorOnDetach"], {reload: false}); + + let detach = yield waitForDetach; + let error = yield waitForError; + ok(detach, "testDirectorScript_errorOnDetach detach event received"); + ok(error, "testDirectorScript_errorOnDetach detach error received"); + assertIsDirectorScriptError(error); +} + +function* installAndEnableDirectorScript(directorManager, directorScriptDef) { + let { scriptId } = directorScriptDef; + + DirectorRegistry.install(scriptId, directorScriptDef); + + let waitForAttach = once(directorManager, "director-script-attach"); + let waitForError = once(directorManager, "director-script-error"); + + directorManager.enableByScriptIds([scriptId], {reload: false}); + + let attachOrErrorEvent = yield Promise.race([waitForAttach, waitForError]); + + return attachOrErrorEvent; +} + +function assertIsDirectorScriptError(error) { + ok(!!error.message, "errors should contain a message"); + ok(!!error.stack, "errors should contain a stack trace"); + ok(!!error.fileName, "errors should contain a fileName"); + ok(typeof error.columnNumber == "number", "errors should contain a columnNumber"); + ok(typeof error.lineNumber == "number", "errors should contain a lineNumber"); + + ok(!!error.directorScriptId, "errors should contain a directorScriptId"); +} diff --git a/devtools/server/tests/browser/browser_directorscript_actors_exports.js b/devtools/server/tests/browser/browser_directorscript_actors_exports.js new file mode 100644 index 000000000..f9ef56f51 --- /dev/null +++ b/devtools/server/tests/browser/browser_directorscript_actors_exports.js @@ -0,0 +1,87 @@ +/* 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 {DirectorManagerFront} = require("devtools/shared/fronts/director-manager"); +const {DirectorRegistry} = require("devtools/server/actors/director-registry"); + +DirectorRegistry.clear(); + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "director-script-target.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + + DirectorRegistry.clear(); + let directorManager = DirectorManagerFront(client, form); + + // director scripts attach method defaults to module.exports + let attachModuleExports = yield testDirectorScriptExports(directorManager, { + scriptId: "testDirectorScript_moduleExports", + scriptCode: "(" + (function () { + module.exports = function () {}; + }).toString() + ")();", + scriptOptions: {} + }); + ok(attachModuleExports.port, "testDirectorScript_moduleExports attach event received"); + + // director scripts attach method can be configured using the attachMethod scriptOptions + let attachExportsAttach = yield testDirectorScriptExports(directorManager, { + scriptId: "testDirectorScript_exportsAttach", + scriptCode: "(" + (function () { + exports.attach = function () {}; + }).toString() + ")();", + scriptOptions: { + attachMethod: "attach" + } + }); + ok(attachExportsAttach.port, "testDirectorScript_exportsAttach attach event received"); + + // director scripts without an attach method generates an error event + let errorUndefinedAttachMethod = yield testDirectorScriptExports(directorManager, { + scriptId: "testDirectorScript_undefinedAttachMethod", + scriptCode: "(" + (function () { + // this director script should raise an error + // because it doesn't export any attach method + }).toString() + ")();", + scriptOptions: { + attachMethod: "attach" + } + }); + let { message } = errorUndefinedAttachMethod; + ok(!!message, "testDirectorScript_undefinedAttachMethod error event received"); + assertIsDirectorScriptError(errorUndefinedAttachMethod); + + yield client.close(); + gBrowser.removeCurrentTab(); + DirectorRegistry.clear(); +}); + +function assertIsDirectorScriptError(error) { + ok(!!error.message, "errors should contain a message"); + ok(!!error.stack, "errors should contain a stack trace"); + ok(!!error.fileName, "errors should contain a fileName"); + ok(typeof error.columnNumber == "number", "errors should contain a columnNumber"); + ok(typeof error.lineNumber == "number", "errors should contain a lineNumber"); + + ok(!!error.directorScriptId, "errors should contain a directorScriptId"); +} + +function* testDirectorScriptExports(directorManager, directorScriptDef) { + let { scriptId } = directorScriptDef; + + DirectorRegistry.install(scriptId, directorScriptDef); + + let waitForAttach = once(directorManager, "director-script-attach"); + let waitForError = once(directorManager, "director-script-error"); + directorManager.enableByScriptIds([scriptId], {reload: false}); + + let attachOrErrorEvent = yield Promise.race([waitForAttach, waitForError]); + + return attachOrErrorEvent; +} diff --git a/devtools/server/tests/browser/browser_markers-cycle-collection.js b/devtools/server/tests/browser/browser_markers-cycle-collection.js new file mode 100644 index 000000000..dc33375f2 --- /dev/null +++ b/devtools/server/tests/browser/browser_markers-cycle-collection.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we get "nsCycleCollector::Collect" and + * "nsCycleCollector::ForgetSkippable" markers when we force cycle collection. + */ + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); + +add_task(function* () { + // This test runs very slowly on linux32 debug EC2 instances. + requestLongerTimeout(2); + + let browser = yield addTab(MAIN_DOMAIN + "doc_force_cc.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = PerformanceFront(client, form); + yield front.connect(); + let rec = yield front.startRecording({ withMarkers: true }); + + let markers = yield waitForMarkerType(front, ["nsCycleCollector::Collect", "nsCycleCollector::ForgetSkippable"]); + yield front.stopRecording(rec); + + ok(markers.some(m => m.name === "nsCycleCollector::Collect"), "got some nsCycleCollector::Collect markers"); + ok(markers.some(m => m.name === "nsCycleCollector::ForgetSkippable"), "got some nsCycleCollector::Collect markers"); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_markers-docloading-01.js b/devtools/server/tests/browser/browser_markers-docloading-01.js new file mode 100644 index 000000000..3c82d56c4 --- /dev/null +++ b/devtools/server/tests/browser/browser_markers-docloading-01.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we get DOMContentLoaded and Load markers + */ + +const { TimelineFront } = require("devtools/shared/fronts/timeline"); +const MARKER_NAMES = ["document::DOMContentLoaded", "document::Load"]; + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "doc_innerHTML.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = TimelineFront(client, form); + let rec = yield front.start({ withMarkers: true }); + + front.once("doc-loading", e => { + ok(false, "Should not be emitting doc-loading events."); + }); + + executeSoon(() => doc.location.reload()); + + yield waitForMarkerType(front, MARKER_NAMES, () => true, e => e, "markers"); + yield front.stop(rec); + + ok(true, "Found the required marker names."); + + // Wait some more time to make sure the 'doc-loading' events never get fired. + yield DevToolsUtils.waitForTime(1000); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_markers-docloading-02.js b/devtools/server/tests/browser/browser_markers-docloading-02.js new file mode 100644 index 000000000..0142ea0cd --- /dev/null +++ b/devtools/server/tests/browser/browser_markers-docloading-02.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we get DOMContentLoaded and Load markers + */ + +const { TimelineFront } = require("devtools/shared/fronts/timeline"); +const MARKER_NAMES = ["document::DOMContentLoaded", "document::Load"]; + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "doc_innerHTML.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = TimelineFront(client, form); + let rec = yield front.start({ withMarkers: true, withDocLoadingEvents: true }); + + yield new Promise(resolve => { + front.once("doc-loading", resolve); + doc.location.reload(); + }); + + ok(true, "At least one doc-loading event got fired."); + + yield waitForMarkerType(front, MARKER_NAMES, () => true, e => e, "markers"); + yield front.stop(rec); + + ok(true, "Found the required marker names."); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_markers-docloading-03.js b/devtools/server/tests/browser/browser_markers-docloading-03.js new file mode 100644 index 000000000..1960da4da --- /dev/null +++ b/devtools/server/tests/browser/browser_markers-docloading-03.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we get DOMContentLoaded and Load markers + */ + +const { TimelineFront } = require("devtools/shared/fronts/timeline"); +const MARKER_NAMES = ["document::DOMContentLoaded", "document::Load"]; + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "doc_innerHTML.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = TimelineFront(client, form); + let rec = yield front.start({ withDocLoadingEvents: true }); + + waitForMarkerType(front, MARKER_NAMES, () => true, e => e, "markers").then(e => { + ok(false, "Should not be emitting doc-loading markers."); + }); + + yield new Promise(resolve => { + front.once("doc-loading", resolve); + doc.location.reload(); + }); + + ok(true, "At least one doc-loading event got fired."); + + yield front.stop(rec); + + // Wait some more time to make sure the 'doc-loading' markers never get fired. + yield DevToolsUtils.waitForTime(1000); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_markers-gc.js b/devtools/server/tests/browser/browser_markers-gc.js new file mode 100644 index 000000000..559b19161 --- /dev/null +++ b/devtools/server/tests/browser/browser_markers-gc.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we get "GarbageCollection" markers. + */ + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); +const MARKER_NAME = "GarbageCollection"; + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "doc_force_gc.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = PerformanceFront(client, form); + yield front.connect(); + let rec = yield front.startRecording({ withMarkers: true }); + + let markers = yield waitForMarkerType(front, MARKER_NAME); + yield front.stopRecording(rec); + + ok(markers.some(m => m.name === MARKER_NAME), `got some ${MARKER_NAME} markers`); + ok(markers.every(({causeName}) => typeof causeName === "string"), + "All markers have a causeName."); + ok(markers.every(({cycle}) => typeof cycle === "number"), + "All markers have a `cycle` ID."); + + markers = rec.getMarkers(); + + // Bug 1197646 + let ordered = true; + markers.reduce((previousStart, current, i) => { + if (i === 0) { + return current.start; + } + if (current.start < previousStart) { + ok(false, `markers must be in order. ${current.name} marker has later start time (${current.start}) thanprevious: ${previousStart}`); + ordered = false; + } + return current.start; + }); + + is(ordered, true, "All GC and non-GC markers are in order by start time."); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_markers-minor-gc.js b/devtools/server/tests/browser/browser_markers-minor-gc.js new file mode 100644 index 000000000..332764348 --- /dev/null +++ b/devtools/server/tests/browser/browser_markers-minor-gc.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we get "MinorGC" markers when we continue to steadily allocate + * objects. + */ + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); + +add_task(function* () { + // This test runs very slowly on linux32 debug EC2 instances. + requestLongerTimeout(2); + + let doc = yield addTab(MAIN_DOMAIN + "doc_allocations.html"); + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = PerformanceFront(client, form); + yield front.connect(); + let rec = yield front.startRecording({ withMarkers: true }); + + let markers = yield waitForMarkerType(front, ["MinorGC"]); + yield front.stopRecording(rec); + + ok(markers.some(m => m.name === "MinorGC" && m.causeName), + "got some MinorGC markers"); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_markers-parse-html.js b/devtools/server/tests/browser/browser_markers-parse-html.js new file mode 100644 index 000000000..bd4f479c0 --- /dev/null +++ b/devtools/server/tests/browser/browser_markers-parse-html.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we get "Parse HTML" markers. + */ + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); +const MARKER_NAME = "Parse HTML"; + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "doc_innerHTML.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = PerformanceFront(client, form); + yield front.connect(); + let rec = yield front.startRecording({ withMarkers: true }); + + let markers = yield waitForMarkerType(front, MARKER_NAME); + yield front.stopRecording(rec); + + ok(markers.some(m => m.name === MARKER_NAME), `got some ${MARKER_NAME} markers`); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_markers-styles.js b/devtools/server/tests/browser/browser_markers-styles.js new file mode 100644 index 000000000..a3dffe8b5 --- /dev/null +++ b/devtools/server/tests/browser/browser_markers-styles.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we get "Styles" markers with correct meta. + */ + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); +const MARKER_NAME = "Styles"; + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "doc_perf.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = PerformanceFront(client, form); + yield front.connect(); + let rec = yield front.startRecording({ withMarkers: true }); + + let markers = yield waitForMarkerType(front, MARKER_NAME, function (markers) { + return markers.some(({restyleHint}) => restyleHint != void 0); + }); + + yield front.stopRecording(rec); + + ok(markers.some(m => m.name === MARKER_NAME), `got some ${MARKER_NAME} markers`); + ok(markers.some(({restyleHint}) => restyleHint != void 0), + "Some markers have a restyleHint."); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_markers-timestamp.js b/devtools/server/tests/browser/browser_markers-timestamp.js new file mode 100644 index 000000000..428499502 --- /dev/null +++ b/devtools/server/tests/browser/browser_markers-timestamp.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we get a "TimeStamp" marker. + */ + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); +const { pmmConsoleMethod, pmmLoadFrameScripts, pmmClearFrameScripts } = require("devtools/client/performance/test/helpers/profiler-mm-utils"); +const MARKER_NAME = "TimeStamp"; + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "doc_perf.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = PerformanceFront(client, form); + yield front.connect(); + let rec = yield front.startRecording({ withMarkers: true }); + + pmmLoadFrameScripts(gBrowser); + pmmConsoleMethod("timeStamp"); + pmmConsoleMethod("timeStamp", "myLabel"); + + let markers = yield waitForMarkerType(front, MARKER_NAME, markers => markers.length >= 2); + + yield front.stopRecording(rec); + + ok(markers.every(({stack}) => typeof stack === "number"), "All markers have stack references."); + ok(markers.every(({name}) => name === "TimeStamp"), "All markers found are TimeStamp markers"); + ok(markers.length === 2, "found 2 TimeStamp markers"); + ok(markers.every(({start, end}) => typeof start === "number" && start === end), + "All markers have equal start and end times"); + is(markers[0].causeName, void 0, "Unlabeled timestamps have an empty causeName"); + is(markers[1].causeName, "myLabel", "Labeled timestamps have correct causeName"); + + pmmClearFrameScripts(); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_navigateEvents.js b/devtools/server/tests/browser/browser_navigateEvents.js new file mode 100644 index 000000000..f8652f197 --- /dev/null +++ b/devtools/server/tests/browser/browser_navigateEvents.js @@ -0,0 +1,160 @@ +/* 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 URL1 = MAIN_DOMAIN + "navigate-first.html"; +const URL2 = MAIN_DOMAIN + "navigate-second.html"; + +var events = require("sdk/event/core"); +var client; + +SpecialPowers.pushPrefEnv({"set": [["dom.require_user_interaction_for_beforeunload", false]]}); + +// State machine to check events order +var i = 0; +function assertEvent(event, data) { + let x = 0; + switch (i++) { + case x++: + is(event, "request", "Get first page load"); + is(data, URL1); + break; + case x++: + is(event, "load-new-document", "Ask to load the second page"); + break; + case x++: + is(event, "unload-dialog", "We get the dialog on first page unload"); + break; + case x++: + is(event, "will-navigate", "The very first event is will-navigate on server side"); + is(data.newURI, URL2, "newURI property is correct"); + break; + case x++: + is(event, "request", "RDP is async with messageManager, the request happens after will-navigate"); + is(data, URL2); + break; + case x++: + is(event, "tabNavigated", "After the request, the client receive tabNavigated"); + is(data.state, "start", "state is start"); + is(data.url, URL2, "url property is correct"); + is(data.nativeConsoleAPI, true, "nativeConsoleAPI is correct"); + break; + case x++: + is(event, "DOMContentLoaded"); + is(content.document.readyState, "interactive"); + break; + case x++: + is(event, "load"); + is(content.document.readyState, "complete"); + break; + case x++: + is(event, "navigate", "Then once the second doc is loaded, we get the navigate event"); + is(content.document.readyState, "complete", "navigate is emitted only once the document is fully loaded"); + break; + case x++: + is(event, "tabNavigated", "Finally, the receive the client event"); + is(data.state, "stop", "state is stop"); + is(data.url, URL2, "url property is correct"); + is(data.nativeConsoleAPI, true, "nativeConsoleAPI is correct"); + + // End of test! + cleanup(); + break; + } +} + +function waitForOnBeforeUnloadDialog(browser, callback) { + browser.addEventListener("DOMWillOpenModalDialog", function onModalDialog() { + browser.removeEventListener("DOMWillOpenModalDialog", onModalDialog, true); + + executeSoon(() => { + let stack = browser.parentNode; + let dialogs = stack.getElementsByTagName("tabmodalprompt"); + let {button0, button1} = dialogs[0].ui; + callback(button0, button1); + }); + }, true); +} + +var httpObserver = function (subject, topic, state) { + let channel = subject.QueryInterface(Ci.nsIHttpChannel); + let url = channel.URI.spec; + // Only listen for our document request, as many other requests can happen + if (url == URL1 || url == URL2) { + assertEvent("request", url); + } +}; +Services.obs.addObserver(httpObserver, "http-on-modify-request", false); + +function onDOMContentLoaded() { + assertEvent("DOMContentLoaded"); +} +function onLoad() { + assertEvent("load"); +} + +function getServerTabActor(callback) { + // Ensure having a minimal server + initDebuggerServer(); + + // Connect to this tab + let transport = DebuggerServer.connectPipe(); + client = new DebuggerClient(transport); + connectDebuggerClient(client).then(form => { + let actorID = form.actor; + client.attachTab(actorID, function (aResponse, aTabClient) { + // !Hack! Retrieve a server side object, the BrowserTabActor instance + let tabActor = DebuggerServer._searchAllConnectionsForActor(actorID); + callback(tabActor); + }); + }); + + client.addListener("tabNavigated", function (aEvent, aPacket) { + assertEvent("tabNavigated", aPacket); + }); +} + +function test() { + // Open a test tab + addTab(URL1).then(function (browser) { + let doc = browser.contentDocument; + getServerTabActor(function (tabActor) { + // In order to listen to internal will-navigate/navigate events + events.on(tabActor, "will-navigate", function (data) { + assertEvent("will-navigate", data); + }); + events.on(tabActor, "navigate", function (data) { + assertEvent("navigate", data); + }); + + // Start listening for page load events + browser.addEventListener("DOMContentLoaded", onDOMContentLoaded, true); + browser.addEventListener("load", onLoad, true); + + // Listen for alert() call being made in navigate-first during unload + waitForOnBeforeUnloadDialog(browser, function (btnLeave, btnStay) { + assertEvent("unload-dialog"); + // accept to quit this page to another + btnLeave.click(); + }); + + // Load another document in this doc to dispatch these events + assertEvent("load-new-document"); + content.location = URL2; + }); + + }); +} + +function cleanup() { + let browser = gBrowser.selectedBrowser; + browser.removeEventListener("DOMContentLoaded", onDOMContentLoaded); + browser.removeEventListener("load", onLoad); + client.close().then(function () { + Services.obs.addObserver(httpObserver, "http-on-modify-request", false); + DebuggerServer.destroy(); + finish(); + }); +} diff --git a/devtools/server/tests/browser/browser_perf-allocation-data.js b/devtools/server/tests/browser/browser_perf-allocation-data.js new file mode 100644 index 000000000..3d4a94dee --- /dev/null +++ b/devtools/server/tests/browser/browser_perf-allocation-data.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we have allocation data coming from the front. + */ + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "doc_allocations.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = PerformanceFront(client, form); + yield front.connect(); + + let rec = yield front.startRecording({ withMarkers: true, withAllocations: true, withTicks: true }); + + yield waitUntil(() => rec.getAllocations().frames.length); + yield waitUntil(() => rec.getAllocations().timestamps.length); + yield waitUntil(() => rec.getAllocations().sizes.length); + yield waitUntil(() => rec.getAllocations().sites.length); + + yield front.stopRecording(rec); + + let { frames, timestamps, sizes, sites } = rec.getAllocations(); + + is(timestamps.length, sizes.length, "we have the same amount of timestamps and sizes"); + ok(timestamps.every(time => time > 0 && typeof time === "number"), "all timestamps have numeric values"); + ok(sizes.every(n => n > 0 && typeof n === "number"), "all sizes are positive numbers"); + + yield front.destroy(); + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_perf-profiler-01.js b/devtools/server/tests/browser/browser_perf-profiler-01.js new file mode 100644 index 000000000..36d200f01 --- /dev/null +++ b/devtools/server/tests/browser/browser_perf-profiler-01.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the profiler connection front does not activate the built-in + * profiler module if not necessary, and doesn't deactivate it when + * a recording is stopped. + */ + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); +const { pmmIsProfilerActive, pmmStopProfiler, pmmLoadFrameScripts } = require("devtools/client/performance/test/helpers/profiler-mm-utils"); + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "doc_perf.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = PerformanceFront(client, form); + yield front.connect(); + + pmmLoadFrameScripts(gBrowser); + + ok(!(yield pmmIsProfilerActive()), + "The built-in profiler module should not have been automatically started."); + + let rec = yield front.startRecording(); + yield front.stopRecording(rec); + ok((yield pmmIsProfilerActive()), + "The built-in profiler module should still be active (1)."); + + rec = yield front.startRecording(); + yield front.stopRecording(rec); + ok((yield pmmIsProfilerActive()), + "The built-in profiler module should still be active (2)."); + + yield front.destroy(); + yield client.close(); + + ok(!(yield pmmIsProfilerActive()), + "The built-in profiler module should no longer be active."); + + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_perf-profiler-02.js b/devtools/server/tests/browser/browser_perf-profiler-02.js new file mode 100644 index 000000000..29842cef7 --- /dev/null +++ b/devtools/server/tests/browser/browser_perf-profiler-02.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the built-in profiler module doesn't deactivate when the toolbox + * is destroyed if there are other consumers using it. + */ + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); +const { pmmIsProfilerActive, pmmStopProfiler, pmmLoadFrameScripts } = require("devtools/client/performance/test/helpers/profiler-mm-utils"); + +add_task(function* () { + yield addTab(MAIN_DOMAIN + "doc_perf.html"); + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let firstFront = PerformanceFront(client, form); + yield firstFront.connect(); + + pmmLoadFrameScripts(gBrowser); + + yield firstFront.startRecording(); + + yield addTab(MAIN_DOMAIN + "doc_perf.html"); + let client2 = new DebuggerClient(DebuggerServer.connectPipe()); + let form2 = yield connectDebuggerClient(client2); + let secondFront = PerformanceFront(client2, form2); + yield secondFront.connect(); + pmmLoadFrameScripts(gBrowser); + + yield secondFront.startRecording(); + + // Manually teardown the tabs so we can check profiler status + yield secondFront.destroy(); + yield client2.close(); + ok((yield pmmIsProfilerActive()), + "The built-in profiler module should still be active."); + + yield firstFront.destroy(); + yield client.close(); + ok(!(yield pmmIsProfilerActive()), + "The built-in profiler module should no longer be active."); + + gBrowser.removeCurrentTab(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_perf-profiler-03.js b/devtools/server/tests/browser/browser_perf-profiler-03.js new file mode 100644 index 000000000..28d87fe45 --- /dev/null +++ b/devtools/server/tests/browser/browser_perf-profiler-03.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the built-in profiler module is not reactivated if no other + * consumer was using it over the remote debugger protocol, and ensures + * that the actor will work properly even in such cases (e.g. the Gecko Profiler + * addon was installed and automatically activated the profiler module). + */ + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); +const { pmmIsProfilerActive, pmmStartProfiler, pmmStopProfiler, pmmLoadFrameScripts, pmmClearFrameScripts } = require("devtools/client/performance/test/helpers/profiler-mm-utils"); + +add_task(function* () { + // Ensure the profiler is already running when the test starts. + pmmLoadFrameScripts(gBrowser); + let entries = 1000000; + let interval = 1; + let features = ["js"]; + yield pmmStartProfiler({ entries, interval, features }); + + ok((yield pmmIsProfilerActive()), + "The built-in profiler module should still be active."); + + yield addTab(MAIN_DOMAIN + "doc_perf.html"); + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let firstFront = PerformanceFront(client, form); + yield firstFront.connect(); + + let recording = yield firstFront.startRecording(); + + yield addTab(MAIN_DOMAIN + "doc_perf.html"); + let client2 = new DebuggerClient(DebuggerServer.connectPipe()); + let form2 = yield connectDebuggerClient(client2); + let secondFront = PerformanceFront(client2, form2); + yield secondFront.connect(); + + yield secondFront.destroy(); + yield client2.close(); + ok((yield pmmIsProfilerActive()), + "The built-in profiler module should still be active."); + + yield firstFront.destroy(); + yield client.close(); + ok(!(yield pmmIsProfilerActive()), + "The built-in profiler module should have been automatically stopped."); + + pmmClearFrameScripts(); + + gBrowser.removeCurrentTab(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_perf-realtime-markers.js b/devtools/server/tests/browser/browser_perf-realtime-markers.js new file mode 100644 index 000000000..b9eab211c --- /dev/null +++ b/devtools/server/tests/browser/browser_perf-realtime-markers.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test functionality of real time markers. + */ + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "doc_perf.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = PerformanceFront(client, form); + yield front.connect(); + + let lastMemoryDelta = 0; + let lastTickDelta = 0; + + let counters = { + markers: [], + memory: [], + ticks: [] + }; + + let deferreds = { + markers: defer(), + memory: defer(), + ticks: defer() + }; + + front.on("timeline-data", handler); + + let rec = yield front.startRecording({ withMarkers: true, withMemory: true, withTicks: true }); + yield Promise.all(Object.keys(deferreds).map(type => deferreds[type].promise)); + yield front.stopRecording(rec); + front.off("timeline-data", handler); + + is(counters.markers.length, 1, "one marker event fired."); + is(counters.memory.length, 3, "three memory events fired."); + is(counters.ticks.length, 3, "three ticks events fired."); + + yield front.destroy(); + yield client.close(); + gBrowser.removeCurrentTab(); + + function handler(name, data) { + if (name === "markers") { + if (counters.markers.length >= 1) { return; } + ok(data.markers[0].start, "received atleast one marker with `start`"); + ok(data.markers[0].end, "received atleast one marker with `end`"); + ok(data.markers[0].name, "received atleast one marker with `name`"); + + counters.markers.push(data.markers); + } + else if (name === "memory") { + if (counters.memory.length >= 3) { return; } + let { delta, measurement } = data; + is(typeof delta, "number", "received `delta` in memory event"); + ok(delta > lastMemoryDelta, "received `delta` in memory event"); + ok(measurement.total, "received `total` in memory event"); + + counters.memory.push({ delta, measurement }); + lastMemoryDelta = delta; + } + else if (name === "ticks") { + if (counters.ticks.length >= 3) { return; } + let { delta, timestamps } = data; + ok(delta > lastTickDelta, "received `delta` in ticks event"); + + // Timestamps aren't guaranteed to always contain tick events, since + // they're dependent on the refresh driver, which may be blocked. + + counters.ticks.push({ delta, timestamps }); + lastTickDelta = delta; + } + else if (name === "frames") { + // Nothing to do here. + } + else { + ok(false, `Received unknown event: ${name}`); + } + + if (name === "markers" && counters[name].length === 1 || + name === "memory" && counters[name].length === 3 || + name === "ticks" && counters[name].length === 3) { + deferreds[name].resolve(); + } + } +}); diff --git a/devtools/server/tests/browser/browser_perf-recording-actor-01.js b/devtools/server/tests/browser/browser_perf-recording-actor-01.js new file mode 100644 index 000000000..683493121 --- /dev/null +++ b/devtools/server/tests/browser/browser_perf-recording-actor-01.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the state of a recording rec from start to finish for recording, + * completed, and rec data. + */ + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "doc_perf.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = PerformanceFront(client, form); + yield front.connect(); + + let rec = yield front.startRecording({ withMarkers: true, withTicks: true, withMemory: true }); + ok(rec.isRecording(), "RecordingModel is recording when created"); + yield busyWait(100); + yield waitUntil(() => rec.getMemory().length); + ok(true, "RecordingModel populates memory while recording"); + yield waitUntil(() => rec.getTicks().length); + ok(true, "RecordingModel populates ticks while recording"); + yield waitUntil(() => rec.getMarkers().length); + ok(true, "RecordingModel populates markers while recording"); + + ok(!rec.isCompleted(), "RecordingModel is not completed when still recording"); + + let stopping = once(front, "recording-stopping"); + let stopped = once(front, "recording-stopped"); + front.stopRecording(rec); + + yield stopping; + ok(!rec.isRecording(), "on 'recording-stopping', model is no longer recording"); + // This handler should be called BEFORE "recording-stopped" is called, as + // there is some delay, but in the event where "recording-stopped" finishes + // before we check here, ensure that we're atleast in the right state + if (rec.getProfile()) { + ok(rec.isCompleted(), "recording is completed once it has profile data"); + } else { + ok(!rec.isCompleted(), "recording is not yet completed on 'recording-stopping'"); + ok(rec.isFinalizing(), "recording is considered finalizing between 'recording-stopping' and 'recording-stopped'"); + } + + yield stopped; + ok(!rec.isRecording(), "on 'recording-stopped', model is still no longer recording"); + ok(rec.isCompleted(), "on 'recording-stopped', model is considered 'complete'"); + + checkSystemInfo(rec, "Host"); + checkSystemInfo(rec, "Client"); + + // Export and import a rec, and ensure it has the correct state. + let file = FileUtils.getFile("TmpD", ["tmpprofile.json"]); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("666", 8)); + yield rec.exportRecording(file); + + let importedModel = yield front.importRecording(file); + + ok(importedModel.isCompleted(), "All imported recordings should be completed"); + ok(!importedModel.isRecording(), "All imported recordings should not be recording"); + ok(importedModel.isImported(), "All imported recordings should be considerd imported"); + + checkSystemInfo(importedModel, "Host"); + checkSystemInfo(importedModel, "Client"); + + yield front.destroy(); + yield client.close(); + gBrowser.removeCurrentTab(); +}); + +function checkSystemInfo(recording, type) { + let data = recording[`get${type}SystemInfo`](); + for (let field of ["appid", "apptype", "vendor", "name", "version"]) { + ok(data[field], `get${type}SystemInfo() has ${field} property`); + } +} diff --git a/devtools/server/tests/browser/browser_perf-recording-actor-02.js b/devtools/server/tests/browser/browser_perf-recording-actor-02.js new file mode 100644 index 000000000..8337ad2ef --- /dev/null +++ b/devtools/server/tests/browser/browser_perf-recording-actor-02.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that buffer status is correctly updated in recording models. + */ + +var BUFFER_SIZE = 20000; +var config = { bufferSize: BUFFER_SIZE }; + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "doc_perf.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = PerformanceFront(client, form); + yield front.connect(); + + yield front.setProfilerStatusInterval(10); + let model = yield front.startRecording(config); + let stats = yield once(front, "profiler-status"); + is(stats.totalSize, BUFFER_SIZE, `profiler-status event has totalSize: ${stats.totalSize}`); + ok(stats.position < BUFFER_SIZE, `profiler-status event has position: ${stats.position}`); + ok(stats.generation >= 0, `profiler-status event has generation: ${stats.generation}`); + ok(stats.isActive, "profiler-status event is isActive"); + is(typeof stats.currentTime, "number", "profiler-status event has currentTime"); + + // Halt once more for a buffer status to ensure we're beyond 0 + yield once(front, "profiler-status"); + + let lastBufferStatus = 0; + let checkCount = 0; + while (lastBufferStatus < 1) { + let currentBufferStatus = front.getBufferUsageForRecording(model); + ok(currentBufferStatus > lastBufferStatus, `buffer is more filled than before: ${currentBufferStatus} > ${lastBufferStatus}`); + lastBufferStatus = currentBufferStatus; + checkCount++; + yield once(front, "profiler-status"); + } + + ok(checkCount >= 1, "atleast 1 event were fired until the buffer was filled"); + is(lastBufferStatus, 1, "buffer usage cannot surpass 100%"); + yield front.stopRecording(model); + + is(front.getBufferUsageForRecording(model), null, "buffer usage should be null when no longer recording."); + + yield front.destroy(); + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_perf-samples-01.js b/devtools/server/tests/browser/browser_perf-samples-01.js new file mode 100644 index 000000000..f8f4bf393 --- /dev/null +++ b/devtools/server/tests/browser/browser_perf-samples-01.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the retrieved profiler data samples are correctly filtered and + * normalized before passed to consumers. + */ + +const WAIT_TIME = 1000; // ms + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "doc_perf.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = PerformanceFront(client, form); + yield front.connect(); + + // Perform the first recording... + + let firstRecording = yield front.startRecording(); + let firstRecordingStartTime = firstRecording._startTime; + info("Started profiling at: " + firstRecordingStartTime); + + busyWait(WAIT_TIME); // allow the profiler module to sample some cpu activity + + yield front.stopRecording(firstRecording); + + ok(firstRecording.getDuration() >= WAIT_TIME, + "The first recording duration is correct."); + + // Perform the second recording... + + let secondRecording = yield front.startRecording(); + let secondRecordingStartTime = secondRecording._startTime; + info("Started profiling at: " + secondRecordingStartTime); + + busyWait(WAIT_TIME); // allow the profiler module to sample more cpu activity + + yield front.stopRecording(secondRecording); + let secondRecordingProfile = secondRecording.getProfile(); + let secondRecordingSamples = secondRecordingProfile.threads[0].samples.data; + + ok(secondRecording.getDuration() >= WAIT_TIME, + "The second recording duration is correct."); + + const TIME_SLOT = secondRecordingProfile.threads[0].samples.schema.time; + ok(secondRecordingSamples[0][TIME_SLOT] < secondRecordingStartTime, + "The second recorded sample times were normalized."); + ok(secondRecordingSamples[0][TIME_SLOT] > 0, + "The second recorded sample times were normalized correctly."); + ok(!secondRecordingSamples.find(e => e[TIME_SLOT] + secondRecordingStartTime <= firstRecording.getDuration()), + "There should be no samples from the first recording in the second one, " + + "even though the total number of frames did not overflow."); + + yield front.destroy(); + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_perf-samples-02.js b/devtools/server/tests/browser/browser_perf-samples-02.js new file mode 100644 index 000000000..c4d51230d --- /dev/null +++ b/devtools/server/tests/browser/browser_perf-samples-02.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if the retrieved profiler data samples always have a (root) node. + * If this ever changes, the |ThreadNode.prototype.insert| function in + * devtools/client/performance/modules/logic/tree-model.js will have to be changed. + */ + +const WAIT_TIME = 1000; // ms + +const { PerformanceFront } = require("devtools/shared/fronts/performance"); + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "doc_perf.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = PerformanceFront(client, form); + yield front.connect(); + + let rec = yield front.startRecording(); + busyWait(WAIT_TIME); // allow the profiler module to sample some cpu activity + + yield front.stopRecording(rec); + let profile = rec.getProfile(); + let sampleCount = 0; + + for (let thread of profile.threads) { + info("Checking thread: " + thread.name); + + for (let sample of thread.samples.data) { + sampleCount++; + + let stack = getInflatedStackLocations(thread, sample); + if (stack[0] != "(root)") { + ok(false, "The sample " + stack.toSource() + " doesn't have a root node."); + } + } + } + + ok(sampleCount > 0, + "At least some samples have been iterated over, checking for root nodes."); + + yield front.destroy(); + yield client.close(); + gBrowser.removeCurrentTab(); +}); + +/** + * Inflate a particular sample's stack and return an array of strings. + */ +function getInflatedStackLocations(thread, sample) { + let stackTable = thread.stackTable; + let frameTable = thread.frameTable; + let stringTable = thread.stringTable; + let SAMPLE_STACK_SLOT = thread.samples.schema.stack; + let STACK_PREFIX_SLOT = stackTable.schema.prefix; + let STACK_FRAME_SLOT = stackTable.schema.frame; + let FRAME_LOCATION_SLOT = frameTable.schema.location; + + // Build the stack from the raw data and accumulate the locations in + // an array. + let stackIndex = sample[SAMPLE_STACK_SLOT]; + let locations = []; + while (stackIndex !== null) { + let stackEntry = stackTable.data[stackIndex]; + let frame = frameTable.data[stackEntry[STACK_FRAME_SLOT]]; + locations.push(stringTable[frame[FRAME_LOCATION_SLOT]]); + stackIndex = stackEntry[STACK_PREFIX_SLOT]; + } + + // The profiler tree is inverted, so reverse the array. + return locations.reverse(); +} diff --git a/devtools/server/tests/browser/browser_register_actor.js b/devtools/server/tests/browser/browser_register_actor.js new file mode 100644 index 000000000..73ee0cedc --- /dev/null +++ b/devtools/server/tests/browser/browser_register_actor.js @@ -0,0 +1,76 @@ +var gClient; + +function test() { + waitForExplicitFinish(); + var {ActorRegistryFront} = require("devtools/shared/fronts/actor-registry"); + var actorURL = "chrome://mochitests/content/chrome/devtools/server/tests/mochitest/hello-actor.js"; + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect() + .then(() => gClient.listTabs()) + .then(aResponse => { + + var options = { + prefix: "helloActor", + constructor: "HelloActor", + type: { tab: true } + }; + + var registry = ActorRegistryFront(gClient, aResponse); + registry.registerActor(actorURL, options).then(actorFront => { + gClient.listTabs(response => { + var tab = response.tabs[response.selected]; + ok(!!tab.helloActor, "Hello actor must exist"); + + // Make sure actor's state is maintained across listTabs requests. + checkActorState(tab.helloActor, () => { + + // Clean up + actorFront.unregister().then(() => { + gClient.close().then(() => { + DebuggerServer.destroy(); + gClient = null; + finish(); + }); + }); + }); + }); + }); + }); +} + +function checkActorState(helloActor, callback) { + getCount(helloActor, response => { + ok(!response.error, "No error"); + is(response.count, 1, "The counter must be valid"); + + getCount(helloActor, response => { + ok(!response.error, "No error"); + is(response.count, 2, "The counter must be valid"); + + gClient.listTabs(response => { + var tab = response.tabs[response.selected]; + is(tab.helloActor, helloActor, "Hello actor must be valid"); + + getCount(helloActor, response => { + ok(!response.error, "No error"); + is(response.count, 3, "The counter must be valid"); + + callback(); + }); + }); + }); + }); +} + +function getCount(actor, callback) { + gClient.request({ + to: actor, + type: "count" + }, callback); +} diff --git a/devtools/server/tests/browser/browser_storage_dynamic_windows.js b/devtools/server/tests/browser/browser_storage_dynamic_windows.js new file mode 100644 index 000000000..440c91222 --- /dev/null +++ b/devtools/server/tests/browser/browser_storage_dynamic_windows.js @@ -0,0 +1,294 @@ +/* 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 {StorageFront} = require("devtools/shared/fronts/storage"); +Services.scriptloader.loadSubScript("chrome://mochitests/content/browser/devtools/server/tests/browser/storage-helpers.js", this); + +const beforeReload = { + cookies: { + "test1.example.org": ["c1", "cs2", "c3", "uc1"], + "sectest1.example.org": ["uc1", "cs2"] + }, + localStorage: { + "http://test1.example.org": ["ls1", "ls2"], + "http://sectest1.example.org": ["iframe-u-ls1"] + }, + sessionStorage: { + "http://test1.example.org": ["ss1"], + "http://sectest1.example.org": ["iframe-u-ss1", "iframe-u-ss2"] + }, + indexedDB: { + "http://test1.example.org": [ + JSON.stringify(["idb1", "obj1"]), + JSON.stringify(["idb1", "obj2"]), + JSON.stringify(["idb2", "obj3"]), + ], + "http://sectest1.example.org": [] + } +}; + +function* testStores(data, front) { + testWindowsBeforeReload(data); + + // FIXME: Bug 1183581 - browser_storage_dynamic_windows.js IsSafeToRunScript + // errors when testing reload in E10S mode + // yield testReload(front); + yield testAddIframe(front); + yield testRemoveIframe(front); +} + +function testWindowsBeforeReload(data) { + for (let storageType in beforeReload) { + ok(data[storageType], storageType + " storage actor is present"); + is(Object.keys(data[storageType].hosts).length, + Object.keys(beforeReload[storageType]).length, + "Number of hosts for " + storageType + "match"); + for (let host in beforeReload[storageType]) { + ok(data[storageType].hosts[host], "Host " + host + " is present"); + } + } +} + +function markOutMatched(toBeEmptied, data, deleted) { + if (!Object.keys(toBeEmptied).length) { + info("Object empty"); + return; + } + ok(Object.keys(data).length, + "At least one storage type should be present"); + for (let storageType in toBeEmptied) { + if (!data[storageType]) { + continue; + } + info("Testing for " + storageType); + for (let host in data[storageType]) { + ok(toBeEmptied[storageType][host], "Host " + host + " found"); + if (!deleted) { + for (let item of data[storageType][host]) { + let index = toBeEmptied[storageType][host].indexOf(item); + ok(index > -1, "Item found - " + item); + if (index > -1) { + toBeEmptied[storageType][host].splice(index, 1); + } + } + if (!toBeEmptied[storageType][host].length) { + delete toBeEmptied[storageType][host]; + } + } else { + delete toBeEmptied[storageType][host]; + } + } + if (!Object.keys(toBeEmptied[storageType]).length) { + delete toBeEmptied[storageType]; + } + } +} + +// function testReload(front) { +// info("Testing if reload works properly"); + +// let shouldBeEmptyFirst = Cu.cloneInto(beforeReload, {}); +// let shouldBeEmptyLast = Cu.cloneInto(beforeReload, {}); +// return new Promise(resolve => { + +// let onStoresUpdate = data => { +// info("in stores update of testReload"); +// // This might be second time stores update is happening, in which case, +// // data.deleted will be null. +// // OR.. This might be the first time on a super slow machine where both +// // data.deleted and data.added is missing in the first update. +// if (data.deleted) { +// markOutMatched(shouldBeEmptyFirst, data.deleted, true); +// } + +// if (!Object.keys(shouldBeEmptyFirst).length) { +// info("shouldBeEmptyFirst is empty now"); +// } + +// // stores-update call might not have data.added for the first time on +// // slow machines, in which case, data.added will be null +// if (data.added) { +// markOutMatched(shouldBeEmptyLast, data.added); +// } + +// if (!Object.keys(shouldBeEmptyLast).length) { +// info("Everything to be received is received."); +// endTestReloaded(); +// } +// }; + +// let endTestReloaded = () => { +// front.off("stores-update", onStoresUpdate); +// resolve(); +// }; + +// front.on("stores-update", onStoresUpdate); + +// content.location.reload(); +// }); +// } + +function testAddIframe(front) { + info("Testing if new iframe addition works properly"); + return new Promise(resolve => { + let shouldBeEmpty = { + localStorage: { + "https://sectest1.example.org": ["iframe-s-ls1"] + }, + sessionStorage: { + "https://sectest1.example.org": ["iframe-s-ss1"] + }, + cookies: { + "sectest1.example.org": ["sc1"] + }, + indexedDB: { + // empty because indexed db creation happens after the page load, so at + // the time of window-ready, there was no indexed db present. + "https://sectest1.example.org": [] + }, + Cache: { + "https://sectest1.example.org":[] + } + }; + + let onStoresUpdate = data => { + info("checking if the hosts list is correct for this iframe addition"); + + markOutMatched(shouldBeEmpty, data.added); + + ok(!data.changed || !data.changed.cookies || + !data.changed.cookies["https://sectest1.example.org"], + "Nothing got changed for cookies"); + ok(!data.changed || !data.changed.localStorage || + !data.changed.localStorage["https://sectest1.example.org"], + "Nothing got changed for local storage"); + ok(!data.changed || !data.changed.sessionStorage || + !data.changed.sessionStorage["https://sectest1.example.org"], + "Nothing got changed for session storage"); + ok(!data.changed || !data.changed.indexedDB || + !data.changed.indexedDB["https://sectest1.example.org"], + "Nothing got changed for indexed db"); + + ok(!data.deleted || !data.deleted.cookies || + !data.deleted.cookies["https://sectest1.example.org"], + "Nothing got deleted for cookies"); + ok(!data.deleted || !data.deleted.localStorage || + !data.deleted.localStorage["https://sectest1.example.org"], + "Nothing got deleted for local storage"); + ok(!data.deleted || !data.deleted.sessionStorage || + !data.deleted.sessionStorage["https://sectest1.example.org"], + "Nothing got deleted for session storage"); + ok(!data.deleted || !data.deleted.indexedDB || + !data.deleted.indexedDB["https://sectest1.example.org"], + "Nothing got deleted for indexed db"); + + if (!Object.keys(shouldBeEmpty).length) { + info("Everything to be received is received."); + endTestReloaded(); + } + }; + + let endTestReloaded = () => { + front.off("stores-update", onStoresUpdate); + resolve(); + }; + + front.on("stores-update", onStoresUpdate); + + let iframe = content.document.createElement("iframe"); + iframe.src = ALT_DOMAIN_SECURED + "storage-secured-iframe.html"; + content.document.querySelector("body").appendChild(iframe); + }); +} + +function testRemoveIframe(front) { + info("Testing if iframe removal works properly"); + return new Promise(resolve => { + let shouldBeEmpty = { + localStorage: { + "http://sectest1.example.org": [] + }, + sessionStorage: { + "http://sectest1.example.org": [] + }, + Cache: { + "http://sectest1.example.org": [] + }, + indexedDB: { + "http://sectest1.example.org": [] + } + }; + + let onStoresUpdate = data => { + info("checking if the hosts list is correct for this iframe deletion"); + + markOutMatched(shouldBeEmpty, data.deleted, true); + + ok(!data.deleted.cookies || !data.deleted.cookies["sectest1.example.org"], + "Nothing got deleted for Cookies as " + + "the same hostname is still present"); + + ok(!data.changed || !data.changed.cookies || + !data.changed.cookies["http://sectest1.example.org"], + "Nothing got changed for cookies"); + ok(!data.changed || !data.changed.localStorage || + !data.changed.localStorage["http://sectest1.example.org"], + "Nothing got changed for local storage"); + ok(!data.changed || !data.changed.sessionStorage || + !data.changed.sessionStorage["http://sectest1.example.org"], + "Nothing got changed for session storage"); + + ok(!data.added || !data.added.cookies || + !data.added.cookies["http://sectest1.example.org"], + "Nothing got added for cookies"); + ok(!data.added || !data.added.localStorage || + !data.added.localStorage["http://sectest1.example.org"], + "Nothing got added for local storage"); + ok(!data.added || !data.added.sessionStorage || + !data.added.sessionStorage["http://sectest1.example.org"], + "Nothing got added for session storage"); + + if (!Object.keys(shouldBeEmpty).length) { + info("Everything to be received is received."); + endTestReloaded(); + } + }; + + let endTestReloaded = () => { + front.off("stores-update", onStoresUpdate); + resolve(); + }; + + front.on("stores-update", onStoresUpdate); + + for (let iframe of content.document.querySelectorAll("iframe")) { + if (iframe.src.startsWith("http:")) { + iframe.remove(); + break; + } + } + }); +} + +add_task(function* () { + yield openTabAndSetupStorage(MAIN_DOMAIN + "storage-dynamic-windows.html"); + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = StorageFront(client, form); + let data = yield front.listStores(); + yield testStores(data, front); + + yield clearStorage(); + + // Forcing GC/CC to get rid of docshells and windows created by this test. + forceCollections(); + yield client.close(); + forceCollections(); + DebuggerServer.destroy(); + forceCollections(); +}); diff --git a/devtools/server/tests/browser/browser_storage_listings.js b/devtools/server/tests/browser/browser_storage_listings.js new file mode 100644 index 000000000..4ff3c3fc1 --- /dev/null +++ b/devtools/server/tests/browser/browser_storage_listings.js @@ -0,0 +1,610 @@ +/* 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 {StorageFront} = require("devtools/shared/fronts/storage"); +Services.scriptloader.loadSubScript("chrome://mochitests/content/browser/devtools/server/tests/browser/storage-helpers.js", this); + +const storeMap = { + cookies: { + "test1.example.org": [ + { + name: "c1", + value: "foobar", + expires: 2000000000000, + path: "/browser", + host: "test1.example.org", + isDomain: false, + isSecure: false, + }, + { + name: "cs2", + value: "sessionCookie", + path: "/", + host: ".example.org", + expires: 0, + isDomain: true, + isSecure: false, + }, + { + name: "c3", + value: "foobar-2", + expires: 2000000001000, + path: "/", + host: "test1.example.org", + isDomain: false, + isSecure: false, + }, + { + name: "uc1", + value: "foobar", + host: ".example.org", + path: "/", + expires: 0, + isDomain: true, + isSecure: true, + } + ], + "sectest1.example.org": [ + { + name: "uc1", + value: "foobar", + host: ".example.org", + path: "/", + expires: 0, + isDomain: true, + isSecure: true, + }, + { + name: "cs2", + value: "sessionCookie", + path: "/", + host: ".example.org", + expires: 0, + isDomain: true, + isSecure: false, + }, + { + name: "sc1", + value: "foobar", + path: "/browser/devtools/server/tests/browser/", + host: "sectest1.example.org", + expires: 0, + isDomain: false, + isSecure: false, + } + ] + }, + localStorage: { + "http://test1.example.org": [ + { + name: "ls1", + value: "foobar" + }, + { + name: "ls2", + value: "foobar-2" + } + ], + "http://sectest1.example.org": [ + { + name: "iframe-u-ls1", + value: "foobar" + } + ], + "https://sectest1.example.org": [ + { + name: "iframe-s-ls1", + value: "foobar" + } + ] + }, + sessionStorage: { + "http://test1.example.org": [ + { + name: "ss1", + value: "foobar-3" + } + ], + "http://sectest1.example.org": [ + { + name: "iframe-u-ss1", + value: "foobar1" + }, + { + name: "iframe-u-ss2", + value: "foobar2" + } + ], + "https://sectest1.example.org": [ + { + name: "iframe-s-ss1", + value: "foobar-2" + } + ] + } +}; + +const IDBValues = { + listStoresResponse: { + "http://test1.example.org": [ + ["idb1", "obj1"], ["idb1", "obj2"], ["idb2", "obj3"] + ], + "http://sectest1.example.org": [ + ], + "https://sectest1.example.org": [ + ["idb-s1", "obj-s1"], ["idb-s2", "obj-s2"] + ] + }, + dbDetails : { + "http://test1.example.org": [ + { + db: "idb1", + origin: "http://test1.example.org", + version: 1, + objectStores: 2 + }, + { + db: "idb2", + origin: "http://test1.example.org", + version: 1, + objectStores: 1 + }, + ], + "http://sectest1.example.org": [ + ], + "https://sectest1.example.org": [ + { + db: "idb-s1", + origin: "https://sectest1.example.org", + version: 1, + objectStores: 1 + }, + { + db: "idb-s2", + origin: "https://sectest1.example.org", + version: 1, + objectStores: 1 + }, + ] + }, + objectStoreDetails: { + "http://test1.example.org": { + idb1: [ + { + objectStore: "obj1", + keyPath: "id", + autoIncrement: false, + indexes: [ + { + name: "name", + keyPath: "name", + "unique": false, + multiEntry: false, + }, + { + name: "email", + keyPath: "email", + "unique": true, + multiEntry: false, + }, + ] + }, + { + objectStore: "obj2", + keyPath: "id2", + autoIncrement: false, + indexes: [] + } + ], + idb2: [ + { + objectStore: "obj3", + keyPath: "id3", + autoIncrement: false, + indexes: [ + { + name: "name2", + keyPath: "name2", + "unique": true, + multiEntry: false, + } + ] + }, + ] + }, + "http://sectest1.example.org" : {}, + "https://sectest1.example.org": { + "idb-s1": [ + { + objectStore: "obj-s1", + keyPath: "id", + autoIncrement: false, + indexes: [] + }, + ], + "idb-s2": [ + { + objectStore: "obj-s2", + keyPath: "id3", + autoIncrement: true, + indexes: [ + { + name: "name2", + keyPath: "name2", + "unique": true, + multiEntry: false, + } + ] + }, + ] + } + + }, + entries: { + "http://test1.example.org": { + "idb1#obj1": [ + { + name: 1, + value: { + id: 1, + name: "foo", + email: "foo@bar.com", + } + }, + { + name: 2, + value: { + id: 2, + name: "foo2", + email: "foo2@bar.com", + } + }, + { + name: 3, + value: { + id: 3, + name: "foo2", + email: "foo3@bar.com", + } + } + ], + "idb1#obj2": [ + { + name: 1, + value: { + id2: 1, + name: "foo", + email: "foo@bar.com", + extra: "baz" + } + } + ], + "idb2#obj3": [] + }, + "http://sectest1.example.org" : {}, + "https://sectest1.example.org": { + "idb-s1#obj-s1": [ + { + name: 6, + value: { + id: 6, + name: "foo", + email: "foo@bar.com", + } + }, + { + name: 7, + value: { + id: 7, + name: "foo2", + email: "foo2@bar.com", + } + } + ], + "idb-s2#obj-s2": [ + { + name: 13, + value: { + id2: 13, + name2: "foo", + email: "foo@bar.com", + } + } + ] + } + } +}; + +function finishTests(client) { + + let closeConnection = () => { + + }; +} + +function* testStores(data) { + ok(data.cookies, "Cookies storage actor is present"); + ok(data.localStorage, "Local Storage storage actor is present"); + ok(data.sessionStorage, "Session Storage storage actor is present"); + ok(data.indexedDB, "Indexed DB storage actor is present"); + yield testCookies(data.cookies); + yield testLocalStorage(data.localStorage); + yield testSessionStorage(data.sessionStorage); + yield testIndexedDB(data.indexedDB); +} + +function testCookies(cookiesActor) { + is(Object.keys(cookiesActor.hosts).length, 2, "Correct number of host entries for cookies"); + return testCookiesObjects(0, cookiesActor.hosts, cookiesActor); +} + +var testCookiesObjects = Task.async(function* (index, hosts, cookiesActor) { + let host = Object.keys(hosts)[index]; + let matchItems = data => { + let cookiesLength = 0; + for (let secureCookie of storeMap.cookies[host]) { + if (secureCookie.isSecure) { + ++cookiesLength; + } + } + // Any secure cookies did not get stored in the database. + is(data.total, storeMap.cookies[host].length - cookiesLength, + "Number of cookies in host " + host + " matches"); + for (let item of data.data) { + let found = false; + for (let toMatch of storeMap.cookies[host]) { + if (item.name == toMatch.name) { + found = true; + ok(true, "Found cookie " + item.name + " in response"); + is(item.value.str, toMatch.value, "The value matches."); + is(item.expires, toMatch.expires, "The expiry time matches."); + is(item.path, toMatch.path, "The path matches."); + is(item.host, toMatch.host, "The host matches."); + is(item.isSecure, toMatch.isSecure, "The isSecure value matches."); + is(item.isDomain, toMatch.isDomain, "The isDomain value matches."); + break; + } + } + ok(found, "cookie " + item.name + " should exist in response"); + } + }; + + ok(!!storeMap.cookies[host], "Host is present in the list : " + host); + matchItems(yield cookiesActor.getStoreObjects(host)); + if (index == Object.keys(hosts).length - 1) { + return; + } + yield testCookiesObjects(++index, hosts, cookiesActor); +}); + +function testLocalStorage(localStorageActor) { + is(Object.keys(localStorageActor.hosts).length, 3, + "Correct number of host entries for local storage"); + return testLocalStorageObjects(0, localStorageActor.hosts, localStorageActor); +} + +var testLocalStorageObjects = Task.async(function* (index, hosts, localStorageActor) { + let host = Object.keys(hosts)[index]; + let matchItems = data => { + is(data.total, storeMap.localStorage[host].length, + "Number of local storage items in host " + host + " matches"); + for (let item of data.data) { + let found = false; + for (let toMatch of storeMap.localStorage[host]) { + if (item.name == toMatch.name) { + found = true; + ok(true, "Found local storage item " + item.name + " in response"); + is(item.value.str, toMatch.value, "The value matches."); + break; + } + } + ok(found, "local storage item " + item.name + " should exist in response"); + } + }; + + ok(!!storeMap.localStorage[host], "Host is present in the list : " + host); + matchItems(yield localStorageActor.getStoreObjects(host)); + if (index == Object.keys(hosts).length - 1) { + return; + } + yield testLocalStorageObjects(++index, hosts, localStorageActor); +}); + +function testSessionStorage(sessionStorageActor) { + is(Object.keys(sessionStorageActor.hosts).length, 3, + "Correct number of host entries for session storage"); + return testSessionStorageObjects(0, sessionStorageActor.hosts, + sessionStorageActor); +} + +var testSessionStorageObjects = Task.async(function* (index, hosts, sessionStorageActor) { + let host = Object.keys(hosts)[index]; + let matchItems = data => { + is(data.total, storeMap.sessionStorage[host].length, + "Number of session storage items in host " + host + " matches"); + for (let item of data.data) { + let found = false; + for (let toMatch of storeMap.sessionStorage[host]) { + if (item.name == toMatch.name) { + found = true; + ok(true, "Found session storage item " + item.name + " in response"); + is(item.value.str, toMatch.value, "The value matches."); + break; + } + } + ok(found, "session storage item " + item.name + " should exist in response"); + } + }; + + ok(!!storeMap.sessionStorage[host], "Host is present in the list : " + host); + matchItems(yield sessionStorageActor.getStoreObjects(host)); + if (index == Object.keys(hosts).length - 1) { + return; + } + yield testSessionStorageObjects(++index, hosts, sessionStorageActor); +}); + +var testIndexedDB = Task.async(function* (indexedDBActor) { + is(Object.keys(indexedDBActor.hosts).length, 3, + "Correct number of host entries for indexed db"); + + for (let host in indexedDBActor.hosts) { + for (let item of indexedDBActor.hosts[host]) { + let parsedItem = JSON.parse(item); + let found = false; + for (let toMatch of IDBValues.listStoresResponse[host]) { + if (toMatch[0] == parsedItem[0] && toMatch[1] == parsedItem[1]) { + found = true; + break; + } + } + ok(found, item + " should exist in list stores response"); + } + } + + yield testIndexedDBs(0, indexedDBActor.hosts, indexedDBActor); + yield testObjectStores(0, indexedDBActor.hosts, indexedDBActor); + yield testIDBEntries(0, indexedDBActor.hosts, indexedDBActor); +}); + +var testIndexedDBs = Task.async(function* (index, hosts, indexedDBActor) { + let host = Object.keys(hosts)[index]; + let matchItems = data => { + is(data.total, IDBValues.dbDetails[host].length, + "Number of indexed db in host " + host + " matches"); + for (let item of data.data) { + let found = false; + for (let toMatch of IDBValues.dbDetails[host]) { + if (item.db == toMatch.db) { + found = true; + ok(true, "Found indexed db " + item.db + " in response"); + is(item.origin, toMatch.origin, "The origin matches."); + is(item.version, toMatch.version, "The version matches."); + is(item.objectStores, toMatch.objectStores, + "The numebr of object stores matches."); + break; + } + } + ok(found, "indexed db " + item.name + " should exist in response"); + } + }; + + ok(!!IDBValues.dbDetails[host], "Host is present in the list : " + host); + matchItems(yield indexedDBActor.getStoreObjects(host)); + if (index == Object.keys(hosts).length - 1) { + return; + } + yield testIndexedDBs(++index, hosts, indexedDBActor); +}); + +var testObjectStores = Task.async(function* (index, hosts, indexedDBActor) { + let host = Object.keys(hosts)[index]; + let matchItems = (data, db) => { + is(data.total, IDBValues.objectStoreDetails[host][db].length, + "Number of object stores in host " + host + " matches"); + for (let item of data.data) { + let found = false; + for (let toMatch of IDBValues.objectStoreDetails[host][db]) { + if (item.objectStore == toMatch.objectStore) { + found = true; + ok(true, "Found object store " + item.objectStore + " in response"); + is(item.keyPath, toMatch.keyPath, "The keyPath matches."); + is(item.autoIncrement, toMatch.autoIncrement, "The autoIncrement matches."); + item.indexes = JSON.parse(item.indexes); + is(item.indexes.length, toMatch.indexes.length, "Number of indexes match"); + for (let index of item.indexes) { + let indexFound = false; + for (let toMatchIndex of toMatch.indexes) { + if (toMatchIndex.name == index.name) { + indexFound = true; + ok(true, "Found index " + index.name); + is(index.keyPath, toMatchIndex.keyPath, + "The keyPath of index matches."); + is(index.unique, toMatchIndex.unique, "The unique matches"); + is(index.multiEntry, toMatchIndex.multiEntry, + "The multiEntry matches"); + break; + } + } + ok(indexFound, "Index " + index + " should exist in response"); + } + break; + } + } + ok(found, "indexed db " + item.name + " should exist in response"); + } + }; + + ok(!!IDBValues.objectStoreDetails[host], "Host is present in the list : " + host); + for (let name of hosts[host]) { + let objName = JSON.parse(name).slice(0, 1); + matchItems(( + yield indexedDBActor.getStoreObjects(host, [JSON.stringify(objName)]) + ), objName[0]); + } + if (index == Object.keys(hosts).length - 1) { + return; + } + yield testObjectStores(++index, hosts, indexedDBActor); +}); + +var testIDBEntries = Task.async(function* (index, hosts, indexedDBActor) { + let host = Object.keys(hosts)[index]; + let matchItems = (data, obj) => { + is(data.total, IDBValues.entries[host][obj].length, + "Number of items in object store " + obj + " matches"); + for (let item of data.data) { + let found = false; + for (let toMatch of IDBValues.entries[host][obj]) { + if (item.name == toMatch.name) { + found = true; + ok(true, "Found indexed db item " + item.name + " in response"); + let value = JSON.parse(item.value.str); + is(Object.keys(value).length, Object.keys(toMatch.value).length, + "Number of entries in the value matches"); + for (let key in value) { + is(value[key], toMatch.value[key], + "value of " + key + " value key matches"); + } + break; + } + } + ok(found, "indexed db item " + item.name + " should exist in response"); + } + }; + + ok(!!IDBValues.entries[host], "Host is present in the list : " + host); + for (let name of hosts[host]) { + let parsed = JSON.parse(name); + matchItems(( + yield indexedDBActor.getStoreObjects(host, [name]) + ), parsed[0] + "#" + parsed[1]); + } + if (index == Object.keys(hosts).length - 1) { + return; + } + yield testObjectStores(++index, hosts, indexedDBActor); +}); + +add_task(function* () { + yield openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html"); + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = StorageFront(client, form); + let data = yield front.listStores(); + yield testStores(data); + + yield clearStorage(); + + // Forcing GC/CC to get rid of docshells and windows created by this test. + forceCollections(); + yield client.close(); + forceCollections(); + DebuggerServer.destroy(); + forceCollections(); +}); diff --git a/devtools/server/tests/browser/browser_storage_updates.js b/devtools/server/tests/browser/browser_storage_updates.js new file mode 100644 index 000000000..28b2e509f --- /dev/null +++ b/devtools/server/tests/browser/browser_storage_updates.js @@ -0,0 +1,304 @@ +/* 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 {StorageFront} = require("devtools/shared/fronts/storage"); +const beforeReload = { + cookies: ["test1.example.org", "sectest1.example.org"], + localStorage: ["http://test1.example.org", "http://sectest1.example.org"], + sessionStorage: ["http://test1.example.org", "http://sectest1.example.org"], +}; + +const TESTS = [ + // index 0 + { + action: function (win) { + info('win.addCookie("c1", "foobar1")'); + win.addCookie("c1", "foobar1"); + + info('win.addCookie("c2", "foobar2")'); + win.addCookie("c2", "foobar2"); + + info('win.localStorage.setItem("l1", "foobar1")'); + win.localStorage.setItem("l1", "foobar1"); + }, + expected: { + added: { + cookies: { + "test1.example.org": ["c1", "c2"] + }, + localStorage: { + "http://test1.example.org": ["l1"] + } + } + } + }, + + // index 1 + { + action: function (win) { + info('win.addCookie("c1", "new_foobar1")'); + win.addCookie("c1", "new_foobar1"); + + info('win.localStorage.setItem("l2", "foobar2")'); + win.localStorage.setItem("l2", "foobar2"); + }, + expected: { + changed: { + cookies: { + "test1.example.org": ["c1"] + } + }, + added: { + localStorage: { + "http://test1.example.org": ["l2"] + } + } + } + }, + + // index 2 + { + action: function (win) { + info('win.removeCookie("c2")'); + win.removeCookie("c2"); + + info('win.localStorage.removeItem("l1")'); + win.localStorage.removeItem("l1"); + + info('win.localStorage.setItem("l3", "foobar3")'); + win.localStorage.setItem("l3", "foobar3"); + }, + expected: { + deleted: { + cookies: { + "test1.example.org": ["c2"] + }, + localStorage: { + "http://test1.example.org": ["l1"] + } + }, + added: { + localStorage: { + "http://test1.example.org": ["l3"] + } + } + } + }, + + // index 3 + { + action: function (win) { + info('win.removeCookie("c1")'); + win.removeCookie("c1"); + + info('win.addCookie("c3", "foobar3")'); + win.addCookie("c3", "foobar3"); + + info('win.localStorage.removeItem("l2")'); + win.localStorage.removeItem("l2"); + + info('win.sessionStorage.setItem("s1", "foobar1")'); + win.sessionStorage.setItem("s1", "foobar1"); + + info('win.sessionStorage.setItem("s2", "foobar2")'); + win.sessionStorage.setItem("s2", "foobar2"); + + info('win.localStorage.setItem("l3", "new_foobar3")'); + win.localStorage.setItem("l3", "new_foobar3"); + }, + expected: { + added: { + cookies: { + "test1.example.org": ["c3"] + }, + sessionStorage: { + "http://test1.example.org": ["s1", "s2"] + } + }, + changed: { + localStorage: { + "http://test1.example.org": ["l3"] + } + }, + deleted: { + cookies: { + "test1.example.org": ["c1"] + }, + localStorage: { + "http://test1.example.org": ["l2"] + } + } + } + }, + + // index 4 + { + action: function (win) { + info('win.sessionStorage.removeItem("s1")'); + win.sessionStorage.removeItem("s1"); + }, + expected: { + deleted: { + sessionStorage: { + "http://test1.example.org": ["s1"] + } + } + } + }, + + // index 5 + { + action: function (win) { + info("win.clearCookies()"); + win.clearCookies(); + }, + expected: { + deleted: { + cookies: { + "test1.example.org": ["c3"] + } + } + } + } +]; + +function markOutMatched(toBeEmptied, data) { + if (!Object.keys(toBeEmptied).length) { + info("Object empty"); + return; + } + ok(Object.keys(data).length, "At least one storage type should be present"); + + for (let storageType in toBeEmptied) { + if (!data[storageType]) { + continue; + } + info("Testing for " + storageType); + for (let host in data[storageType]) { + ok(toBeEmptied[storageType][host], "Host " + host + " found"); + + for (let item of data[storageType][host]) { + let index = toBeEmptied[storageType][host].indexOf(item); + ok(index > -1, "Item found - " + item); + if (index > -1) { + toBeEmptied[storageType][host].splice(index, 1); + } + } + if (!toBeEmptied[storageType][host].length) { + delete toBeEmptied[storageType][host]; + } + } + if (!Object.keys(toBeEmptied[storageType]).length) { + delete toBeEmptied[storageType]; + } + } +} + +function onStoresUpdate(expected, {added, changed, deleted}, index) { + info("inside stores update for index " + index); + + // Here, added, changed and deleted might be null even if they are required as + // per expected. This is fine as they might come in the next stores-update + // call or have already come in the previous one. + if (added) { + info("matching added object for index " + index); + markOutMatched(expected.added, added); + } + if (changed) { + info("matching changed object for index " + index); + markOutMatched(expected.changed, changed); + } + if (deleted) { + info("matching deleted object for index " + index); + markOutMatched(expected.deleted, deleted); + } + + if ((!expected.added || !Object.keys(expected.added).length) && + (!expected.changed || !Object.keys(expected.changed).length) && + (!expected.deleted || !Object.keys(expected.deleted).length)) { + info("Everything expected has been received for index " + index); + } else { + info("Still some updates pending for index " + index); + } +} + +function runTest({action, expected}, front, win, index) { + return new Promise(resolve => { + front.once("stores-update", function (addedChangedDeleted) { + onStoresUpdate(expected, addedChangedDeleted, index); + resolve(); + }); + + info("Running test at index " + index); + action(win); + }); +} + +function* testClearLocalAndSessionStores(front, win) { + return new Promise(resolve => { + // We need to wait until we have received stores-cleared for both local and + // session storage. + let localStorage = false; + let sessionStorage = false; + + front.on("stores-cleared", function onStoresCleared(data) { + storesCleared(data); + + if (data.localStorage) { + localStorage = true; + } + if (data.sessionStorage) { + sessionStorage = true; + } + if (localStorage && sessionStorage) { + front.off("stores-cleared", onStoresCleared); + resolve(); + } + }); + + win.clearLocalAndSessionStores(); + }); +} + +function storesCleared(data) { + if (data.sessionStorage || data.localStorage) { + let hosts = data.sessionStorage || data.localStorage; + info("Stores cleared required for session storage"); + is(hosts.length, 1, "number of hosts is 1"); + is(hosts[0], "http://test1.example.org", + "host matches for " + Object.keys(data)[0]); + } else { + ok(false, "Stores cleared should only be for local and session storage"); + } +} + +function* finishTests(client) { + yield client.close(); + DebuggerServer.destroy(); + finish(); +} + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "storage-updates.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = StorageFront(client, form); + let win = doc.defaultView.wrappedJSObject; + + yield front.listStores(); + + for (let i = 0; i < TESTS.length; i++) { + let test = TESTS[i]; + yield runTest(test, front, win, i); + } + + yield testClearLocalAndSessionStores(front, win); + yield finishTests(client); +}); diff --git a/devtools/server/tests/browser/browser_stylesheets_getTextEmpty.js b/devtools/server/tests/browser/browser_stylesheets_getTextEmpty.js new file mode 100644 index 000000000..f7bb7057e --- /dev/null +++ b/devtools/server/tests/browser/browser_stylesheets_getTextEmpty.js @@ -0,0 +1,40 @@ +/* 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 StyleSheetActor.getText handles empty text correctly. + +const {StyleSheetsFront} = require("devtools/shared/fronts/stylesheets"); + +const CONTENT = "<style>body { background-color: #f0c; }</style>"; +const TEST_URI = "data:text/html;charset=utf-8," + encodeURIComponent(CONTENT); + +add_task(function* () { + yield addTab(TEST_URI); + + info("Initialising the debugger server and client."); + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + + info("Attaching to the active tab."); + yield client.attachTab(form.actor); + + let front = StyleSheetsFront(client, form); + ok(front, "The StyleSheetsFront was created."); + + let sheets = yield front.getStyleSheets(); + ok(sheets, "getStyleSheets() succeeded"); + is(sheets.length, 1, + "getStyleSheets() returned the correct number of sheets"); + + let sheet = sheets[0]; + yield sheet.update("", false); + let longStr = yield sheet.getText(); + let source = yield longStr.string(); + is(source, "", "text is empty"); + + yield client.close(); +}); diff --git a/devtools/server/tests/browser/browser_stylesheets_nested-iframes.js b/devtools/server/tests/browser/browser_stylesheets_nested-iframes.js new file mode 100644 index 000000000..c382b6ce8 --- /dev/null +++ b/devtools/server/tests/browser/browser_stylesheets_nested-iframes.js @@ -0,0 +1,38 @@ +/* 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 StyleSheetsActor.getStyleSheets() works if an iframe does not have +// a content document. + +const {StyleSheetsFront} = require("devtools/shared/fronts/stylesheets"); + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "stylesheets-nested-iframes.html"); + let doc = browser.contentDocument; + + info("Initialising the debugger server and client."); + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + + info("Attaching to the active tab."); + yield client.attachTab(form.actor); + + let front = StyleSheetsFront(client, form); + ok(front, "The StyleSheetsFront was created."); + + let sheets = yield front.getStyleSheets(); + ok(sheets, "getStyleSheets() succeeded even with documentless iframes."); + + // Bug 285395 limits the number of nested iframes to 10. There's one sheet per + // frame so we should get 10 sheets. However, the limit might change in the + // future so it's better not to rely on the limit. Asserting > 2 ensures that + // the test page is actually loading nested iframes and this test is doing + // something sensible (if we got this far, the test has served its purpose). + ok(sheets.length > 2, sheets.length + " sheets found (expected 3 or more)."); + + yield client.close(); +}); diff --git a/devtools/server/tests/browser/browser_timeline.js b/devtools/server/tests/browser/browser_timeline.js new file mode 100644 index 000000000..1e5793447 --- /dev/null +++ b/devtools/server/tests/browser/browser_timeline.js @@ -0,0 +1,63 @@ +/* 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 timeline front's start/stop/isRecording methods work in a +// simple use case, and that markers events are sent when operations occur. +// Note that this test isn't concerned with which markers are actually recorded, +// just that markers are recorded at all. +// Trying to check marker types here may lead to intermittents, see bug 1066474. + +const {TimelineFront} = require("devtools/shared/fronts/timeline"); + +add_task(function* () { + let browser = yield addTab("data:text/html;charset=utf-8,mop"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = TimelineFront(client, form); + + ok(front, "The TimelineFront was created"); + + let isActive = yield front.isRecording(); + ok(!isActive, "The TimelineFront is not initially recording"); + + info("Flush any pending reflows"); + let forceSyncReflow = doc.body.innerHeight; + + info("Start recording"); + yield front.start({ withMarkers: true }); + + isActive = yield front.isRecording(); + ok(isActive, "The TimelineFront is now recording"); + + info("Change some style on the page to cause style/reflow/paint"); + let onMarkers = once(front, "markers"); + doc.body.style.padding = "10px"; + let markers = yield onMarkers; + + ok(true, "The markers event was fired"); + ok(markers.length > 0, "Markers were returned"); + + info("Flush pending reflows again"); + forceSyncReflow = doc.body.innerHeight; + + info("Change some style on the page to cause style/paint"); + onMarkers = once(front, "markers"); + doc.body.style.backgroundColor = "red"; + markers = yield onMarkers; + + ok(markers.length > 0, "markers were returned"); + + yield front.stop(); + + isActive = yield front.isRecording(); + ok(!isActive, "Not recording after stop()"); + + yield client.close(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_timeline_actors.js b/devtools/server/tests/browser/browser_timeline_actors.js new file mode 100644 index 000000000..a902775fa --- /dev/null +++ b/devtools/server/tests/browser/browser_timeline_actors.js @@ -0,0 +1,69 @@ +/* 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 timeline can also record data from the memory and framerate +// actors, emitted as events in tadem with the markers. + +const {TimelineFront} = require("devtools/shared/fronts/timeline"); + +add_task(function* () { + let browser = yield addTab("data:text/html;charset=utf-8,mop"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = TimelineFront(client, form); + + info("Start timeline marker recording"); + yield front.start({ withMemory: true, withTicks: true }); + + let updatedMemory = 0; + let updatedTicks = 0; + + front.on("memory", (delta, measurement) => { + ok(delta > 0, "The delta should be a timestamp."); + ok(measurement, "The measurement should not be null."); + ok(measurement.total > 0, "There should be a 'total' value in the measurement."); + info("Received 'memory' event at " + delta + " with " + measurement.toSource()); + updatedMemory++; + }); + + front.on("ticks", (delta, ticks) => { + ok(delta > 0, "The delta should be a timestamp."); + ok(ticks, "The ticks should not be null."); + info("Received 'ticks' event with " + ticks.toSource()); + updatedTicks++; + }); + + ok((yield waitUntil(() => updatedMemory > 1)), + "Some memory measurements were emitted."); + ok((yield waitUntil(() => updatedTicks > 1)), + "Some refresh driver ticks were emitted."); + + info("Stop timeline marker recording"); + yield front.stop(); + yield client.close(); + gBrowser.removeCurrentTab(); +}); + +/** + * 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).then(() => resolve(true)); + }, interval)); +} diff --git a/devtools/server/tests/browser/browser_timeline_iframes.js b/devtools/server/tests/browser/browser_timeline_iframes.js new file mode 100644 index 000000000..60728873f --- /dev/null +++ b/devtools/server/tests/browser/browser_timeline_iframes.js @@ -0,0 +1,41 @@ +/* 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 the timeline front receives markers events for operations that occur in +// iframes. + +const {TimelineFront} = require("devtools/shared/fronts/timeline"); + +add_task(function* () { + let browser = yield addTab(MAIN_DOMAIN + "timeline-iframe-parent.html"); + let doc = browser.contentDocument; + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let front = TimelineFront(client, form); + + info("Start timeline marker recording"); + yield front.start({ withMarkers: true }); + + // Check that we get markers for a few iterations of the timer that runs in + // the child frame. + for (let i = 0; i < 3; i++) { + yield wait(300); // That's the time the child frame waits before changing styles. + let markers = yield once(front, "markers"); + ok(markers.length, "Markers were received for operations in the child frame"); + } + + info("Stop timeline marker recording"); + yield front.stop(); + yield client.close(); + gBrowser.removeCurrentTab(); +}); + +function wait(ms) { + return new Promise(resolve => + setTimeout(resolve, ms)); +} diff --git a/devtools/server/tests/browser/director-script-target.html b/devtools/server/tests/browser/director-script-target.html new file mode 100644 index 000000000..0b0b56d64 --- /dev/null +++ b/devtools/server/tests/browser/director-script-target.html @@ -0,0 +1,15 @@ +<html> + <head> + <script> + // change the eval function to ensure the window object in the debug-script is correctly wrapped + window.eval = function () { + return "unsecure-eval-called"; + }; + + var globalAccessibleVar = "global-value"; + </script> + </head> + <body> + <h1>debug script target</h1> + </body> +</html> diff --git a/devtools/server/tests/browser/doc_allocations.html b/devtools/server/tests/browser/doc_allocations.html new file mode 100644 index 000000000..314c40cac --- /dev/null +++ b/devtools/server/tests/browser/doc_allocations.html @@ -0,0 +1,21 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> +<script> +window.allocs = []; +window.onload = function() { + function allocator() { + for (var i = 0; i < 1000; i++) { + window.allocs.push(new Object); + } + } + + window.setInterval(allocator, 1); +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/browser/doc_force_cc.html b/devtools/server/tests/browser/doc_force_cc.html new file mode 100644 index 000000000..d5868bd6b --- /dev/null +++ b/devtools/server/tests/browser/doc_force_cc.html @@ -0,0 +1,29 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Performance tool + cycle collection test page</title> + </head> + + <body> + <script type="text/javascript"> + window.test = function () { + document.body.expando1 = { cycle: document.body }; + SpecialPowers.Cu.forceCC(); + + document.body.expando2 = { cycle: document.body }; + SpecialPowers.Cu.forceCC(); + + document.body.expando3 = { cycle: document.body }; + SpecialPowers.Cu.forceCC(); + + setTimeout(window.test, 100); + }; + test(); + </script> + </body> + +</html> diff --git a/devtools/server/tests/browser/doc_force_gc.html b/devtools/server/tests/browser/doc_force_gc.html new file mode 100644 index 000000000..f8b617533 --- /dev/null +++ b/devtools/server/tests/browser/doc_force_gc.html @@ -0,0 +1,27 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Performance tool + garbage collection test page</title> + </head> + + <body> + <script type="text/javascript"> + var x = 1; + window.test = function () { + SpecialPowers.Cu.forceGC(); + document.body.style.borderTop = x + "px solid red"; + x = 1^x; + document.body.innerHeight; // flush pending reflows + + // Prevent this script from being garbage collected. + setTimeout(window.test, 100); + }; + test(); + </script> + </body> + +</html> diff --git a/devtools/server/tests/browser/doc_innerHTML.html b/devtools/server/tests/browser/doc_innerHTML.html new file mode 100644 index 000000000..f5ce72de2 --- /dev/null +++ b/devtools/server/tests/browser/doc_innerHTML.html @@ -0,0 +1,21 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Performance tool + innerHTML test page</title> + </head> + + <body> + <script type="text/javascript"> + "use strict"; + window.test = function () { + document.body.innerHTML = "<h1>LOL</h1>"; + }; + setInterval(window.test, 100); + </script> + </body> + +</html> diff --git a/devtools/server/tests/browser/doc_perf.html b/devtools/server/tests/browser/doc_perf.html new file mode 100644 index 000000000..1da36328b --- /dev/null +++ b/devtools/server/tests/browser/doc_perf.html @@ -0,0 +1,25 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Performance test page</title> + </head> + + <body> + <script type="text/javascript"> + var x = 1; + function test() { + document.body.style.borderTop = x + "px solid red"; + x = 1^x; + document.body.innerHeight; // flush pending reflows + } + + // Prevent this script from being garbage collected. + window.setInterval(test, 1); + </script> + </body> + +</html> diff --git a/devtools/server/tests/browser/head.js b/devtools/server/tests/browser/head.js new file mode 100644 index 000000000..1e7f09d95 --- /dev/null +++ b/devtools/server/tests/browser/head.js @@ -0,0 +1,203 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var Cc = Components.classes; +var Ci = Components.interfaces; +var Cu = Components.utils; + +const {console} = Cu.import("resource://gre/modules/Console.jsm", {}); +const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const {DebuggerClient} = require("devtools/shared/client/main"); +const {DebuggerServer} = require("devtools/server/main"); +const {defer} = require("promise"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const Services = require("Services"); + +const PATH = "browser/devtools/server/tests/browser/"; +const MAIN_DOMAIN = "http://test1.example.org/" + PATH; +const ALT_DOMAIN = "http://sectest1.example.org/" + PATH; +const ALT_DOMAIN_SECURED = "https://sectest1.example.org:443/" + PATH; + +// All tests are asynchronous. +waitForExplicitFinish(); + +/** + * 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 + * @return a promise that resolves to the new browser that the document + * is loaded in. Note that we cannot return the document + * directly, since this would be a CPOW in the e10s case, + * and Promises cannot be resolved with CPOWs (see bug 1233497). + */ +var addTab = Task.async(function* (url) { + info(`Adding a new tab with URL: ${url}`); + let tab = gBrowser.selectedTab = gBrowser.addTab(url); + yield BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + info(`Tab added and URL ${url} loaded`); + + return tab.linkedBrowser; +}); + +function* initAnimationsFrontForUrl(url) { + const {AnimationsFront} = require("devtools/shared/fronts/animation"); + const {InspectorFront} = require("devtools/shared/fronts/inspector"); + + yield addTab(url); + + initDebuggerServer(); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + let form = yield connectDebuggerClient(client); + let inspector = InspectorFront(client, form); + let walker = yield inspector.getWalker(); + let animations = AnimationsFront(client, form); + + return {inspector, walker, animations, client}; +} + +function initDebuggerServer() { + try { + // Sometimes debugger server does not get destroyed correctly by previous + // tests. + DebuggerServer.destroy(); + } catch (e) { + info(`DebuggerServer destroy error: ${e}\n${e.stack}`); + } + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); +} + +/** + * Connect a debugger client. + * @param {DebuggerClient} + * @return {Promise} Resolves to the selected tabActor form when the client is + * connected. + */ +function connectDebuggerClient(client) { + return client.connect() + .then(() => client.listTabs()) + .then(tabs => { + return tabs.tabs[tabs.selected]; + }); +} + +/** + * 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) { + info("Waiting for event: '" + eventName + "' on " + target + "."); + + return new Promise(resolve => { + + 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 + "."); + target[remove](eventName, onEvent, useCapture); + resolve(...aArgs); + }, useCapture); + break; + } + } + }); +} + +/** + * Forces GC, CC and Shrinking GC to get rid of disconnected docshells and + * windows. + */ +function forceCollections() { + Cu.forceGC(); + Cu.forceCC(); + Cu.forceShrinkingGC(); +} + +/** + * Get a mock tabActor from a given window. + * This is sometimes useful to test actors or classes that use the tabActor in + * isolation. + * @param {DOMWindow} win + * @return {Object} + */ +function getMockTabActor(win) { + return { + window: win, + isRootActor: true + }; +} + +registerCleanupFunction(function tearDown() { + while (gBrowser.tabs.length > 1) { + gBrowser.removeCurrentTab(); + } +}); + +function idleWait(time) { + return DevToolsUtils.waitForTime(time); +} + +function busyWait(time) { + let start = Date.now(); + let stack; + while (Date.now() - start < time) { stack = Components.stack; } +} + +/** + * 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).then(() => resolve(true)); + }, interval); + }); +} + +function waitForMarkerType(front, types, predicate, + unpackFun = (name, data) => data.markers, + eventName = "timeline-data") +{ + types = [].concat(types); + predicate = predicate || function () { return true; }; + let filteredMarkers = []; + let { promise, resolve } = defer(); + + info("Waiting for markers of type: " + types); + + function handler(name, data) { + if (typeof name === "string" && name !== "markers") { + return; + } + + let markers = unpackFun(name, data); + info("Got markers: " + JSON.stringify(markers, null, 2)); + + filteredMarkers = filteredMarkers.concat(markers.filter(m => types.indexOf(m.name) !== -1)); + + if (types.every(t => filteredMarkers.some(m => m.name === t)) && predicate(filteredMarkers)) { + front.off(eventName, handler); + resolve(filteredMarkers); + } + } + front.on(eventName, handler); + + return promise; +} diff --git a/devtools/server/tests/browser/navigate-first.html b/devtools/server/tests/browser/navigate-first.html new file mode 100644 index 000000000..829372427 --- /dev/null +++ b/devtools/server/tests/browser/navigate-first.html @@ -0,0 +1,15 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> +First +<script> + +window.onbeforeunload=function(e){ + e.returnValue="?"; +}; +</script> +</body> +</html> diff --git a/devtools/server/tests/browser/navigate-second.html b/devtools/server/tests/browser/navigate-second.html new file mode 100644 index 000000000..4b30fe465 --- /dev/null +++ b/devtools/server/tests/browser/navigate-second.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> +Second +</body> +</html> diff --git a/devtools/server/tests/browser/storage-dynamic-windows.html b/devtools/server/tests/browser/storage-dynamic-windows.html new file mode 100644 index 000000000..67aa35d67 --- /dev/null +++ b/devtools/server/tests/browser/storage-dynamic-windows.html @@ -0,0 +1,117 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 965872 - Storage inspector actor with cookies, local storage and session storage. +--> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for listing hosts and storages</title> +</head> +<body> +<iframe src="http://sectest1.example.org/browser/devtools/server/tests/browser/storage-unsecured-iframe.html"></iframe> +<script type="application/javascript;version=1.7"> +"use strict"; +const partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1]; +const cookieExpiresTime1 = 2000000000000; +const cookieExpiresTime2 = 2000000001000; +// Setting up some cookies to eat. +document.cookie = "c1=foobar; expires=" + + new Date(cookieExpiresTime1).toGMTString() + "; path=/browser"; +document.cookie = "cs2=sessionCookie; path=/; domain=" + partialHostname; +document.cookie = "c3=foobar-2; expires=" + + new Date(cookieExpiresTime2).toGMTString() + "; path=/"; +// ... and some local storage items .. +localStorage.setItem("ls1", "foobar"); +localStorage.setItem("ls2", "foobar-2"); +// ... and finally some session storage items too +sessionStorage.setItem("ss1", "foobar-3"); + +let idbGenerator = function*() { + let request = indexedDB.open("idb1", 1); + request.onerror = function() { + throw new Error("error opening db connection"); + }; + let db = yield new Promise(done => { + request.onupgradeneeded = event => { + let db = event.target.result; + let store1 = db.createObjectStore("obj1", { keyPath: "id" }); + store1.createIndex("name", "name", { unique: false }); + store1.createIndex("email", "email", { unique: true }); + let store2 = db.createObjectStore("obj2", { keyPath: "id2" }); + store1.transaction.oncomplete = () => { + done(db); + }; + }; + }); + + // Prevents AbortError + yield new Promise(done => { + request.onsuccess = done; + }); + + let transaction = db.transaction(["obj1", "obj2"], "readwrite"); + let store1 = transaction.objectStore("obj1"); + let store2 = transaction.objectStore("obj2"); + store1.add({id: 1, name: "foo", email: "foo@bar.com"}); + store1.add({id: 2, name: "foo2", email: "foo2@bar.com"}); + store1.add({id: 3, name: "foo2", email: "foo3@bar.com"}); + store2.add({ + id2: 1, + name: "foo", + email: "foo@bar.com", + extra: "baz" + }); + // Prevents AbortError during close() + yield new Promise(success => { + transaction.oncomplete = success; + }); + + db.close(); + + request = indexedDB.open("idb2", 1); + let db2 = yield new Promise(done => { + request.onupgradeneeded = event => { + let db2 = event.target.result; + let store3 = db2.createObjectStore("obj3", { keyPath: "id3" }); + store3.createIndex("name2", "name2", { unique: true }); + store3.transaction.oncomplete = () => { + done(db2); + } + }; + }); + // Prevents AbortError during close() + yield new Promise(done => { + request.onsuccess = done; + }); + db2.close(); + + console.log("added cookies and stuff from main page"); +}; + +function deleteDB(dbName) { + return new Promise(resolve => { + dump("removing database " + dbName + " from " + document.location + "\n"); + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); +} + +window.setup = function*() { + yield idbGenerator(); +}; + +window.clear = function*() { + document.cookie = "c1=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + document.cookie = "c3=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + document.cookie = "cs2=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + + localStorage.clear(); + + yield deleteDB("idb1"); + yield deleteDB("idb2"); + + dump("removed cookies, localStorage and indexedDB data from " + + document.location + "\n"); +}; +</script> +</body> +</html> diff --git a/devtools/server/tests/browser/storage-helpers.js b/devtools/server/tests/browser/storage-helpers.js new file mode 100644 index 000000000..1c4f37705 --- /dev/null +++ b/devtools/server/tests/browser/storage-helpers.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This generator function opens the given url in a new tab, then sets up the + * page by waiting for all cookies, indexedDB items etc. to be created. + * + * @param url {String} The url to be opened in the new tab + * + * @return {Promise} A promise that resolves after storage inspector is ready + */ +function* openTabAndSetupStorage(url) { + let content = yield addTab(url); + + // Setup the async storages in main window and for all its iframes + yield ContentTask.spawn(gBrowser.selectedBrowser, null, function* () { + /** + * Get all windows including frames recursively. + * + * @param {Window} [baseWindow] + * The base window at which to start looking for child windows + * (optional). + * @return {Set} + * A set of windows. + */ + function getAllWindows(baseWindow) { + let windows = new Set(); + + let _getAllWindows = function (win) { + windows.add(win.wrappedJSObject); + + for (let i = 0; i < win.length; i++) { + _getAllWindows(win[i]); + } + }; + _getAllWindows(baseWindow); + + return windows; + } + + let windows = getAllWindows(content); + for (let win of windows) { + if (win.setup) { + yield win.setup(); + } + } + }); +} + +function* clearStorage() { + yield ContentTask.spawn(gBrowser.selectedBrowser, null, function* () { + /** + * Get all windows including frames recursively. + * + * @param {Window} [baseWindow] + * The base window at which to start looking for child windows + * (optional). + * @return {Set} + * A set of windows. + */ + function getAllWindows(baseWindow) { + let windows = new Set(); + + let _getAllWindows = function (win) { + windows.add(win.wrappedJSObject); + + for (let i = 0; i < win.length; i++) { + _getAllWindows(win[i]); + } + }; + _getAllWindows(baseWindow); + + return windows; + } + + let windows = getAllWindows(content); + for (let win of windows) { + if (win.clear) { + yield win.clear(); + } + } + }); +} diff --git a/devtools/server/tests/browser/storage-listings.html b/devtools/server/tests/browser/storage-listings.html new file mode 100644 index 000000000..c3b7ef3c8 --- /dev/null +++ b/devtools/server/tests/browser/storage-listings.html @@ -0,0 +1,123 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 965872 - Storage inspector actor with cookies, local storage and session storage. +--> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for listing hosts and storages</title> +</head> +<body> +<iframe src="http://sectest1.example.org/browser/devtools/server/tests/browser/storage-unsecured-iframe.html"></iframe> +<iframe src="https://sectest1.example.org:443/browser/devtools/server/tests/browser/storage-secured-iframe.html"></iframe> +<script type="application/javascript;version=1.7"> +"use strict"; +const partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1]; +const cookieExpiresTime1 = 2000000000000; +const cookieExpiresTime2 = 2000000001000; +// Setting up some cookies to eat. +document.cookie = "c1=foobar; expires=" + + new Date(cookieExpiresTime1).toGMTString() + "; path=/browser"; +document.cookie = "cs2=sessionCookie; path=/; domain=" + partialHostname; +document.cookie = "c3=foobar-2; secure=true; expires=" + + new Date(cookieExpiresTime2).toGMTString() + "; path=/"; +// ... and some local storage items .. +localStorage.setItem("ls1", "foobar"); +localStorage.setItem("ls2", "foobar-2"); +// ... and finally some session storage items too +sessionStorage.setItem("ss1", "foobar-3"); +console.log("added cookies and stuff from main page"); + +let idbGenerator = function*() { + let request = indexedDB.open("idb1", 1); + request.onerror = function() { + throw new Error("error opening db connection"); + }; + let db = yield new Promise(done => { + request.onupgradeneeded = event => { + let db = event.target.result; + let store1 = db.createObjectStore("obj1", { keyPath: "id" }); + store1.createIndex("name", "name", { unique: false }); + store1.createIndex("email", "email", { unique: true }); + let store2 = db.createObjectStore("obj2", { keyPath: "id2" }); + store1.transaction.oncomplete = () => { + done(db); + }; + }; + }); + + // Prevents AbortError + yield new Promise(done => { + request.onsuccess = done; + }); + + let transaction = db.transaction(["obj1", "obj2"], "readwrite"); + let store1 = transaction.objectStore("obj1"); + let store2 = transaction.objectStore("obj2"); + store1.add({id: 1, name: "foo", email: "foo@bar.com"}); + store1.add({id: 2, name: "foo2", email: "foo2@bar.com"}); + store1.add({id: 3, name: "foo2", email: "foo3@bar.com"}); + store2.add({ + id2: 1, + name: "foo", + email: "foo@bar.com", + extra: "baz" + }); + // Prevents AbortError during close() + yield new Promise(success => { + transaction.oncomplete = success; + }); + + db.close(); + + request = indexedDB.open("idb2", 1); + let db2 = yield new Promise(done => { + request.onupgradeneeded = event => { + let db2 = event.target.result; + let store3 = db2.createObjectStore("obj3", { keyPath: "id3" }); + store3.createIndex("name2", "name2", { unique: true }); + store3.transaction.oncomplete = () => { + done(db2); + } + }; + }); + // Prevents AbortError during close() + yield new Promise(done => { + request.onsuccess = done; + }); + db2.close(); + + dump("added cookies and stuff from main page\n"); +}; + +function deleteDB(dbName) { + return new Promise(resolve => { + dump("removing database " + dbName + " from " + document.location + "\n"); + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); +} + +window.setup = function*() { + yield idbGenerator(); +}; + +window.clear = function*() { + document.cookie = "c1=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/browser"; + document.cookie = + "c3=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; secure=true"; + document.cookie = + "cs2=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=" + + partialHostname; + + localStorage.clear(); + sessionStorage.clear(); + + yield deleteDB("idb1"); + yield deleteDB("idb2"); + + dump("removed cookies, localStorage, sessionStorage and indexedDB data " + + "from " + document.location + "\n"); +}; +</script> +</body> +</html> diff --git a/devtools/server/tests/browser/storage-secured-iframe.html b/devtools/server/tests/browser/storage-secured-iframe.html new file mode 100644 index 000000000..860b20aab --- /dev/null +++ b/devtools/server/tests/browser/storage-secured-iframe.html @@ -0,0 +1,94 @@ +<!DOCTYPE HTML> +<html> +<!-- +Iframe for testing multiple host detetion in storage actor +--> +<head> + <meta charset="utf-8"> +</head> +<body> +<script type="application/javascript;version=1.7"> + +document.cookie = "sc1=foobar;"; +localStorage.setItem("iframe-s-ls1", "foobar"); +sessionStorage.setItem("iframe-s-ss1", "foobar-2"); + +let idbGenerator = function*() { + let request = indexedDB.open("idb-s1", 1); + request.onerror = function() { + throw new Error("error opening db connection"); + }; + let db = yield new Promise(done => { + request.onupgradeneeded = event => { + let db = event.target.result; + let store1 = db.createObjectStore("obj-s1", { keyPath: "id" }); + store1.transaction.oncomplete = () => { + done(db); + }; + }; + }); + yield new Promise(done => { + request.onsuccess = done; + }); + + let transaction = db.transaction(["obj-s1"], "readwrite"); + let store1 = transaction.objectStore("obj-s1"); + store1.add({id: 6, name: "foo", email: "foo@bar.com"}); + store1.add({id: 7, name: "foo2", email: "foo2@bar.com"}); + yield new Promise(success => { + transaction.oncomplete = success; + }); + + db.close(); + + request = indexedDB.open("idb-s2", 1); + let db2 = yield new Promise(done => { + request.onupgradeneeded = event => { + let db2 = event.target.result; + let store3 = + db2.createObjectStore("obj-s2", { keyPath: "id3", autoIncrement: true }); + store3.createIndex("name2", "name2", { unique: true }); + store3.transaction.oncomplete = () => { + done(db2); + }; + }; + }); + yield new Promise(done => { + request.onsuccess = done; + }); + + transaction = db2.transaction(["obj-s2"], "readwrite"); + let store3 = transaction.objectStore("obj-s2"); + store3.add({id3: 16, name2: "foo", email: "foo@bar.com"}); + yield new Promise(success => { + transaction.oncomplete = success; + }); + + db2.close(); + dump("added cookies and stuff from secured iframe\n"); +} + +function deleteDB(dbName) { + return new Promise(resolve => { + dump("removing database " + dbName + " from " + document.location + "\n"); + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); +} + +window.setup = function*() { + yield idbGenerator(); +}; + +window.clear = function*() { + document.cookie = "sc1=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + + localStorage.clear(); + + yield deleteDB("idb-s1"); + yield deleteDB("idb-s2"); + + console.log("removed cookies and stuff from secured iframe"); +} +</script> +</body> +</html> diff --git a/devtools/server/tests/browser/storage-unsecured-iframe.html b/devtools/server/tests/browser/storage-unsecured-iframe.html new file mode 100644 index 000000000..d339fb7f2 --- /dev/null +++ b/devtools/server/tests/browser/storage-unsecured-iframe.html @@ -0,0 +1,26 @@ +<!DOCTYPE HTML> +<html> +<!-- +Iframe for testing multiple host detetion in storage actor +--> +<head> + <meta charset="utf-8"> +</head> +<body> +<script> + +document.cookie = "uc1=foobar; domain=.example.org; path=/; secure=true"; +localStorage.setItem("iframe-u-ls1", "foobar"); +sessionStorage.setItem("iframe-u-ss1", "foobar1"); +sessionStorage.setItem("iframe-u-ss2", "foobar2"); +console.log("added cookies and stuff from unsecured iframe"); + +window.clear = function*() { + document.cookie = "uc1=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + localStorage.clear(); + sessionStorage.clear(); + console.log("removed cookies and stuff from unsecured iframe"); +} +</script> +</body> +</html> diff --git a/devtools/server/tests/browser/storage-updates.html b/devtools/server/tests/browser/storage-updates.html new file mode 100644 index 000000000..6ffaf4316 --- /dev/null +++ b/devtools/server/tests/browser/storage-updates.html @@ -0,0 +1,47 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 965872 - Storage inspector actor with cookies, local storage and session storage. +--> +<head> + <meta charset="utf-8"> + <title>Storage inspector blank html for tests</title> +</head> +<body> +<script type="application/javascript;version=1.7"> +"use strict"; +window.addCookie = function(name, value, path, domain, expires, secure) { + let cookieString = name + "=" + value + ";"; + if (path) { + cookieString += "path=" + path + ";"; + } + if (domain) { + cookieString += "domain=" + domain + ";"; + } + if (expires) { + cookieString += "expires=" + expires + ";"; + } + if (secure) { + cookieString += "secure=true;"; + } + document.cookie = cookieString; +}; + +window.removeCookie = function(name) { + document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT"; +}; + +window.clearLocalAndSessionStores = function() { + localStorage.clear(); + sessionStorage.clear(); +}; + +window.clearCookies = function() { + let cookies = document.cookie; + for (let cookie of cookies.split(";")) { + removeCookie(cookie.split("=")[0]); + } +}; +</script> +</body> +</html> diff --git a/devtools/server/tests/browser/stylesheets-nested-iframes.html b/devtools/server/tests/browser/stylesheets-nested-iframes.html new file mode 100644 index 000000000..7ee775323 --- /dev/null +++ b/devtools/server/tests/browser/stylesheets-nested-iframes.html @@ -0,0 +1,25 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>StyleSheetsActor iframe test</title> + <style> + p { + padding: 1em; + } + </style> +</head> +<body> + <p>A test page with nested iframes</p> + <iframe></iframe> + <script type="application/javascript;version=1.8"> + let iframe = document.querySelector("iframe"); + let i = parseInt(location.href.split("?")[1]) || 1; + + // The frame can't have the same src URL as any of its ancestors. + // This will not infinitely recurse because a frame won't get a content + // document once it's nested deeply enough. + iframe.src = location.href.split("?")[0] + "?" + (++i); + </script> +</body> +</html> diff --git a/devtools/server/tests/browser/timeline-iframe-child.html b/devtools/server/tests/browser/timeline-iframe-child.html new file mode 100644 index 000000000..5385c6485 --- /dev/null +++ b/devtools/server/tests/browser/timeline-iframe-child.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Timeline iframe test - child frame</title> +</head> +<body> + <h1>Child frame</h1> + <script> + var h1 = document.querySelector("h1"); + setInterval(function() { + h1.style.backgroundColor = "rgb(" + ((Math.random()*255)|0) + "," + + ((Math.random()*255)|0) + "," + + ((Math.random()*255)|0) +")"; + h1.style.width = ((Math.random()*500)|0) + "px"; + }, 300); + </script> +</body> +</html> diff --git a/devtools/server/tests/browser/timeline-iframe-parent.html b/devtools/server/tests/browser/timeline-iframe-parent.html new file mode 100644 index 000000000..b94ba4259 --- /dev/null +++ b/devtools/server/tests/browser/timeline-iframe-parent.html @@ -0,0 +1,11 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Timeline iframe test - parent frame</title> +</head> +<body> + <h1>Parent frame</h1> + <iframe src="timeline-iframe-child.html"></iframe> +</body> +</html> diff --git a/devtools/server/tests/mochitest/.eslintrc.js b/devtools/server/tests/mochitest/.eslintrc.js new file mode 100644 index 000000000..c5b919ce3 --- /dev/null +++ b/devtools/server/tests/mochitest/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the common devtools mochitest eslintrc config. + "extends": "../../../.eslintrc.xpcshell.js" +}; diff --git a/devtools/server/tests/mochitest/Debugger.Source.prototype.element-2.js b/devtools/server/tests/mochitest/Debugger.Source.prototype.element-2.js new file mode 100644 index 000000000..eab746921 --- /dev/null +++ b/devtools/server/tests/mochitest/Debugger.Source.prototype.element-2.js @@ -0,0 +1 @@ +debugger; diff --git a/devtools/server/tests/mochitest/Debugger.Source.prototype.element.html b/devtools/server/tests/mochitest/Debugger.Source.prototype.element.html new file mode 100644 index 000000000..fcf4c6c85 --- /dev/null +++ b/devtools/server/tests/mochitest/Debugger.Source.prototype.element.html @@ -0,0 +1,17 @@ +<head> + <!-- Static (not dynamically inserted) inline script. --> + <script id='franz'>function franz() { debugger; }</script> + + <!-- Static out-of-line script element. --> + <script id='heinrich' src='Debugger.Source.prototype.element.js'></script> +</head> + +<!-- HTML requires some body element onfoo attributes to add handlers to the + *window*, not the element --- but Debugger.Source.prototype.element should + return the element. Here, that rule should apply to the body's 'onresize' + handler. (For the reason for the 'cancelable' check, see the code that + sends the event.) --> +<body onresize='if (event.cancelable) debugger;'> + <!-- Ordinary content element with event handler. --> + <div id='heidi' onclick='heinrichFun();'>Heidi</div> +</body> diff --git a/devtools/server/tests/mochitest/Debugger.Source.prototype.element.js b/devtools/server/tests/mochitest/Debugger.Source.prototype.element.js new file mode 100644 index 000000000..44115b373 --- /dev/null +++ b/devtools/server/tests/mochitest/Debugger.Source.prototype.element.js @@ -0,0 +1 @@ +function heinrichFun() { franz(); } diff --git a/devtools/server/tests/mochitest/animation-data.html b/devtools/server/tests/mochitest/animation-data.html new file mode 100644 index 000000000..01be59548 --- /dev/null +++ b/devtools/server/tests/mochitest/animation-data.html @@ -0,0 +1,120 @@ +<html> +<head> + <meta charset="UTF-8"> + <title>Animation Test Data</title> + <style> + .ball { + width: 80px; + height: 80px; + border-radius: 50%; + background: #f06; + + position: absolute; + } + + .still { + top: 0; + left: 10px; + } + + .animated { + top: 100px; + left: 10px; + + animation: simple-animation 2s infinite alternate; + } + + .multi { + top: 200px; + left: 10px; + + animation: simple-animation 2s infinite alternate, + other-animation 5s infinite alternate; + } + + .delayed { + top: 300px; + left: 10px; + background: rebeccapurple; + + animation: simple-animation 3s 60s 10; + } + + .multi-finite { + top: 400px; + left: 10px; + background: yellow; + + animation: simple-animation 3s, + other-animation 4s; + } + + .short { + top: 500px; + left: 10px; + background: red; + + animation: simple-animation 2s; + } + + .long { + top: 600px; + left: 10px; + background: blue; + + animation: simple-animation 120s; + } + + .negative-delay { + top: 700px; + left: 10px; + background: gray; + + animation: simple-animation 15s -10s; + animation-fill-mode: forwards; + } + + .no-compositor { + top: 0; + right: 10px; + background: gold; + + animation: no-compositor 10s cubic-bezier(.57,-0.02,1,.31) forwards; + } + + @keyframes simple-animation { + 100% { + transform: translateX(300px); + } + } + + @keyframes other-animation { + 100% { + background: blue; + } + } + + @keyframes no-compositor { + 100% { + margin-right: 600px; + } + } + </style> + <script type="text/javascript"> + window.onload = function() { + window.opener.postMessage('ready', '*'); + }; + </script> +</head> +</body> + <div class="ball still"></div> + <div class="ball animated"></div> + <div class="ball multi"></div> + <div class="ball delayed"></div> + <div class="ball multi-finite"></div> + <div class="ball short"></div> + <div class="ball long"></div> + <div class="ball negative-delay"></div> + <div class="ball no-compositor"></div> +</body> +</html> diff --git a/devtools/server/tests/mochitest/chrome.ini b/devtools/server/tests/mochitest/chrome.ini new file mode 100644 index 000000000..ae69d163e --- /dev/null +++ b/devtools/server/tests/mochitest/chrome.ini @@ -0,0 +1,103 @@ +[DEFAULT] +tags = devtools +skip-if = os == 'android' +support-files = + animation-data.html + Debugger.Source.prototype.element.js + Debugger.Source.prototype.element-2.js + Debugger.Source.prototype.element.html + director-helpers.js + hello-actor.js + inspector_css-properties.html + inspector_getImageData.html + inspector-delay-image-response.sjs + inspector-eyedropper.html + inspector-helpers.js + inspector-search-data.html + inspector-styles-data.css + inspector-styles-data.html + inspector-traversal-data.html + large-image.jpg + memory-helpers.js + nonchrome_unsafeDereference.html + small-image.gif + setup-in-child.js + setup-in-parent.js + +[test_animation_actor-lifetime.html] +[test_connection-manager.html] +[test_connectToChild.html] +[test_css-logic.html] +[test_css-logic-media-queries.html] +[test_css-logic-specificity.html] +[test_css-properties_01.html] +[test_css-properties_02.html] +[test_Debugger.Source.prototype.introductionScript.html] +[test_Debugger.Source.prototype.introductionType.html] +[test_Debugger.Source.prototype.element.html] +[test_Debugger.Script.prototype.global.html] +[test_device.html] +[test_director.html] +[test_director_connectToChild.html] +[test_executeInGlobal-outerized_this.html] +[test_framerate_01.html] +[test_framerate_02.html] +[test_framerate_03.html] +[test_framerate_04.html] +[test_framerate_05.html] +[test_framerate_06.html] +[test_getProcess.html] +[test_inspector-anonymous.html] +[test_inspector-changeattrs.html] +[test_inspector-changevalue.html] +[test_inspector-dead-nodes.html] +[test_inspector-duplicate-node.html] +[test_inspector_getImageData.html] +[test_inspector_getImageDataFromURL.html] +[test_inspector_getImageData-wait-for-load.html] +[test_inspector_getNodeFromActor.html] +[test_inspector-hide.html] +[test_inspector-insert.html] +[test_inspector-mutations-attr.html] +[test_inspector-mutations-events.html] +[test_inspector-mutations-childlist.html] +[test_inspector-mutations-frameload.html] +[test_inspector-mutations-value.html] +[test_inspector-pick-color.html] +[test_inspector-pseudoclass-lock.html] +[test_inspector-release.html] +[test_inspector-reload.html] +[test_inspector-remove.html] +[test_inspector-resize.html] +[test_inspector-resolve-url.html] +[test_inspector-retain.html] +[test_inspector-search.html] +[test_inspector-search-front.html] +[test_inspector-scroll-into-view.html] +[test_inspector-traversal.html] +[test_makeGlobalObjectReference.html] +[test_memory.html] +[test_memory_allocations_01.html] +[test_memory_allocations_02.html] +[test_memory_allocations_03.html] +[test_memory_allocations_04.html] +[test_memory_allocations_05.html] +[test_memory_allocations_06.html] +[test_memory_allocations_07.html] +[test_memory_attach_01.html] +[test_memory_attach_02.html] +[test_memory_census.html] +[test_memory_gc_01.html] +[test_memory_gc_events.html] +[test_preference.html] +[test_settings.html] +[test_setupInParentChild.html] +[test_styles-applied.html] +[test_styles-computed.html] +[test_styles-layout.html] +[test_styles-matched.html] +[test_styles-modify.html] +[test_styles-svg.html] +[test_unsafeDereference.html] +[test_websocket-server.html] +skip-if = false diff --git a/devtools/server/tests/mochitest/director-helpers.js b/devtools/server/tests/mochitest/director-helpers.js new file mode 100644 index 000000000..fe1f7d394 --- /dev/null +++ b/devtools/server/tests/mochitest/director-helpers.js @@ -0,0 +1,44 @@ +var Cu = Components.utils; +const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const {DebuggerClient} = require("devtools/shared/client/main"); +const {DebuggerServer} = require("devtools/server/main"); +const Services = require("Services"); + +// Always log packets when running tests. +Services.prefs.setBoolPref("devtools.debugger.log", true); +Services.prefs.setBoolPref("dom.mozBrowserFramesEnabled", true); + +SimpleTest.registerCleanupFunction(function () { + Services.prefs.clearUserPref("devtools.debugger.log"); + Services.prefs.clearUserPref("dom.mozBrowserFramesEnabled"); +}); + +const { DirectorRegistry } = require("devtools/server/actors/director-registry"); +const { DirectorRegistryFront } = require("devtools/shared/fronts/director-registry"); + +const { DirectorManagerFront } = require("devtools/shared/fronts/director-manager"); + +const { Task } = require("devtools/shared/task"); + +/** ********************************* + * director helpers functions + **********************************/ + +function* newConnectedDebuggerClient(opts) { + var transport = DebuggerServer.connectPipe(); + var client = new DebuggerClient(transport); + + yield client.connect(); + + var root = yield client.listTabs(); + + return { + client: client, + root: root, + transport: transport + }; +} + +function purgeInstalledDirectorScripts() { + DirectorRegistry.clear(); +} diff --git a/devtools/server/tests/mochitest/hello-actor.js b/devtools/server/tests/mochitest/hello-actor.js new file mode 100644 index 000000000..603192938 --- /dev/null +++ b/devtools/server/tests/mochitest/hello-actor.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const protocol = require("devtools/shared/protocol"); + +const helloSpec = protocol.generateActorSpec({ + typeName: "helloActor", + + methods: { + count: { + request: {}, + response: {count: protocol.RetVal("number")} + } + } +}); + +var HelloActor = protocol.ActorClassWithSpec(helloSpec, { + initialize: function () { + protocol.Actor.prototype.initialize.apply(this, arguments); + this.counter = 0; + }, + + count: function () { + return ++this.counter; + } +}); diff --git a/devtools/server/tests/mochitest/inspector-delay-image-response.sjs b/devtools/server/tests/mochitest/inspector-delay-image-response.sjs new file mode 100644 index 000000000..215d1e4d8 --- /dev/null +++ b/devtools/server/tests/mochitest/inspector-delay-image-response.sjs @@ -0,0 +1,42 @@ +/** + * Adapted from https://dxr.mozilla.org/mozilla-central/rev/ + * 4e883591bb5dff021c108d3e30198a99547eed1e/layout/reftests/backgrounds/ + * delay-image-response.sjs + */ +"use strict"; + +// A 1x1 PNG image. +// Source: https://commons.wikimedia.org/wiki/File:1x1.png (Public Domain) +const IMAGE = atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" + + "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="); + +// To avoid GC. +let timer = null; + +function handleRequest(request, response) { + let query = {}; + request.queryString.split("&").forEach(function(val) { + let [name, value] = val.split("="); + query[name] = unescape(value); + }); + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "image/png", false); + + // If there is no delay, we write the image and leave. + if (!("delay" in query)) { + response.write(IMAGE); + return; + } + + // If there is a delay, we create a timer which, when it fires, will write + // image and leave. + response.processAsync(); + const nsITimer = Components.interfaces.nsITimer; + + timer = Components.classes["@mozilla.org/timer;1"].createInstance(nsITimer); + timer.initWithCallback(function() { + response.write(IMAGE); + response.finish(); + }, query.delay, nsITimer.TYPE_ONE_SHOT); +} diff --git a/devtools/server/tests/mochitest/inspector-eyedropper.html b/devtools/server/tests/mochitest/inspector-eyedropper.html new file mode 100644 index 000000000..ded9f392d --- /dev/null +++ b/devtools/server/tests/mochitest/inspector-eyedropper.html @@ -0,0 +1,18 @@ +<html>
+<head>
+ <meta charset="UTF-8">
+ <title>Inspector Eyedropper tests</title>
+ <style>
+ html {
+ background: black;
+ }
+ </style>
+ <script type="text/javascript">
+ window.onload = function() {
+ window.opener.postMessage('ready', '*');
+ };
+ </script>
+</head>
+</body>
+</body>
+</html>
\ No newline at end of file diff --git a/devtools/server/tests/mochitest/inspector-helpers.js b/devtools/server/tests/mochitest/inspector-helpers.js new file mode 100644 index 000000000..47c643868 --- /dev/null +++ b/devtools/server/tests/mochitest/inspector-helpers.js @@ -0,0 +1,310 @@ +var Cu = Components.utils; + +const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const {DebuggerClient} = require("devtools/shared/client/main"); +const {DebuggerServer} = require("devtools/server/main"); +const { Task } = require("devtools/shared/task"); + +const Services = require("Services"); +const promise = require("promise"); +const {_documentWalker} = require("devtools/server/actors/inspector"); + +// Always log packets when running tests. +Services.prefs.setBoolPref("devtools.debugger.log", true); +SimpleTest.registerCleanupFunction(function () { + Services.prefs.clearUserPref("devtools.debugger.log"); +}); + + +if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + SimpleTest.registerCleanupFunction(function () { + DebuggerServer.destroy(); + }); +} + +var gAttachCleanups = []; + +SimpleTest.registerCleanupFunction(function () { + for (let cleanup of gAttachCleanups) { + cleanup(); + } +}); + +/** + * Open a tab, load the url, wait for it to signal its readiness, + * find the tab with the debugger server, and call the callback. + * + * Returns a function which can be called to close the opened ta + * and disconnect its debugger client. + */ +function attachURL(url, callback) { + var win = window.open(url, "_blank"); + var client = null; + + let cleanup = () => { + if (client) { + client.close(); + client = null; + } + if (win) { + win.close(); + win = null; + } + }; + gAttachCleanups.push(cleanup); + + window.addEventListener("message", function loadListener(event) { + if (event.data === "ready") { + client = new DebuggerClient(DebuggerServer.connectPipe()); + client.connect().then(([applicationType, traits]) => { + client.listTabs(response => { + for (let tab of response.tabs) { + if (tab.url === url) { + window.removeEventListener("message", loadListener, false); + client.attachTab(tab.actor, function (aResponse, aTabClient) { + try { + callback(null, client, tab, win.document); + } catch (ex) { + Cu.reportError(ex); + dump(ex); + } + }); + break; + } + } + }); + }); + } + }, false); + + return cleanup; +} + +function promiseOnce(target, event) { + let deferred = promise.defer(); + target.on(event, (...args) => { + if (args.length === 1) { + deferred.resolve(args[0]); + } else { + deferred.resolve(args); + } + }); + return deferred.promise; +} + +function sortOwnershipChildren(children) { + return children.sort((a, b) => a.name.localeCompare(b.name)); +} + +function serverOwnershipSubtree(walker, node) { + let actor = walker._refMap.get(node); + if (!actor) { + return undefined; + } + + let children = []; + let docwalker = new _documentWalker(node, window); + let child = docwalker.firstChild(); + while (child) { + let item = serverOwnershipSubtree(walker, child); + if (item) { + children.push(item); + } + child = docwalker.nextSibling(); + } + return { + name: actor.actorID, + children: sortOwnershipChildren(children) + }; +} + +function serverOwnershipTree(walker) { + let serverWalker = DebuggerServer._searchAllConnectionsForActor(walker.actorID); + + return { + root: serverOwnershipSubtree(serverWalker, serverWalker.rootDoc), + orphaned: [...serverWalker._orphaned].map(o => serverOwnershipSubtree(serverWalker, o.rawNode)), + retained: [...serverWalker._retainedOrphans].map(o => serverOwnershipSubtree(serverWalker, o.rawNode)) + }; +} + +function clientOwnershipSubtree(node) { + return { + name: node.actorID, + children: sortOwnershipChildren(node.treeChildren().map(child => clientOwnershipSubtree(child))) + }; +} + +function clientOwnershipTree(walker) { + return { + root: clientOwnershipSubtree(walker.rootNode), + orphaned: [...walker._orphaned].map(o => clientOwnershipSubtree(o)), + retained: [...walker._retainedOrphans].map(o => clientOwnershipSubtree(o)) + }; +} + +function ownershipTreeSize(tree) { + let size = 1; + for (let child of tree.children) { + size += ownershipTreeSize(child); + } + return size; +} + +function assertOwnershipTrees(walker) { + let serverTree = serverOwnershipTree(walker); + let clientTree = clientOwnershipTree(walker); + is(JSON.stringify(clientTree, null, " "), JSON.stringify(serverTree, null, " "), "Server and client ownership trees should match."); + + return ownershipTreeSize(clientTree.root); +} + +// Verify that an actorID is inaccessible both from the client library and the server. +function checkMissing(client, actorID) { + let deferred = promise.defer(); + let front = client.getActor(actorID); + ok(!front, "Front shouldn't be accessible from the client for actorID: " + actorID); + + deferred = promise.defer(); + client.request({ + to: actorID, + type: "request", + }, response => { + is(response.error, "noSuchActor", "node list actor should no longer be contactable."); + deferred.resolve(undefined); + }); + return deferred.promise; +} + +// Verify that an actorID is accessible both from the client library and the server. +function checkAvailable(client, actorID) { + let deferred = promise.defer(); + let front = client.getActor(actorID); + ok(front, "Front should be accessible from the client for actorID: " + actorID); + + deferred = promise.defer(); + client.request({ + to: actorID, + type: "garbageAvailableTest", + }, response => { + is(response.error, "unrecognizedPacketType", "node list actor should be contactable."); + deferred.resolve(undefined); + }); + return deferred.promise; +} + +function promiseDone(promise) { + promise.then(null, err => { + ok(false, "Promise failed: " + err); + if (err.stack) { + dump(err.stack); + } + SimpleTest.finish(); + }); +} + +// Mutation list testing + +function isSrcChange(change) { + return (change.type === "attributes" && change.attributeName === "src"); +} + +function assertAndStrip(mutations, message, test) { + let size = mutations.length; + mutations = mutations.filter(test); + ok((mutations.size != size), message); + return mutations; +} + +function isSrcChange(change) { + return change.type === "attributes" && change.attributeName === "src"; +} + +function isUnload(change) { + return change.type === "documentUnload"; +} + +function isFrameLoad(change) { + return change.type === "frameLoad"; +} + +function isUnretained(change) { + return change.type === "unretained"; +} + +function isChildList(change) { + return change.type === "childList"; +} + +function isNewRoot(change) { + return change.type === "newRoot"; +} + +// Make sure an iframe's src attribute changed and then +// strip that mutation out of the list. +function assertSrcChange(mutations) { + return assertAndStrip(mutations, "Should have had an iframe source change.", isSrcChange); +} + +// Make sure there's an unload in the mutation list and strip +// that mutation out of the list +function assertUnload(mutations) { + return assertAndStrip(mutations, "Should have had a document unload change.", isUnload); +} + +// Make sure there's a frame load in the mutation list and strip +// that mutation out of the list +function assertFrameLoad(mutations) { + return assertAndStrip(mutations, "Should have had a frame load change.", isFrameLoad); +} + +// Make sure there's a childList change in the mutation list and strip +// that mutation out of the list +function assertChildList(mutations) { + return assertAndStrip(mutations, "Should have had a frame load change.", isChildList); +} + +// Load mutations aren't predictable, so keep accumulating mutations until +// the one we're looking for shows up. +function waitForMutation(walker, test, mutations = []) { + let deferred = promise.defer(); + for (let change of mutations) { + if (test(change)) { + deferred.resolve(mutations); + } + } + + walker.once("mutations", newMutations => { + waitForMutation(walker, test, mutations.concat(newMutations)).then(finalMutations => { + deferred.resolve(finalMutations); + }); + }); + + return deferred.promise; +} + + +var _tests = []; +function addTest(test) { + _tests.push(test); +} + +function addAsyncTest(generator) { + _tests.push(() => Task.spawn(generator).then(null, ok.bind(null, false))); +} + +function runNextTest() { + if (_tests.length == 0) { + SimpleTest.finish(); + return; + } + var fn = _tests.shift(); + try { + fn(); + } catch (ex) { + info("Test function " + (fn.name ? "'" + fn.name + "' " : "") + + "threw an exception: " + ex); + } +} diff --git a/devtools/server/tests/mochitest/inspector-search-data.html b/devtools/server/tests/mochitest/inspector-search-data.html new file mode 100644 index 000000000..d0d5f9c68 --- /dev/null +++ b/devtools/server/tests/mochitest/inspector-search-data.html @@ -0,0 +1,52 @@ +<html> +<head> + <meta charset="UTF-8"> + <title>Inspector Search Test Data</title> + <style> + #pseudo { + display: block; + margin: 0; + } + #pseudo:before { + content: "before element"; + } + #pseudo:after { + content: "after element"; + } + </style> + <script type="text/javascript"> + window.onload = function() { + window.opener.postMessage('ready', '*'); + }; + </script> +</head> +</body> + <!-- A comment + spread across multiple lines --> + + <img width="100" height="100" src="large-image.jpg" /> + + <h1 id="pseudo">Heading 1</h1> + <p>A p tag with the text 'h1' inside of it. + <strong>A strong h1 result</strong> + </p> + + <div id="arrows" northwest="↖" northeast="↗" southeast="↘" southwest="↙"> + Unicode arrows + </div> + + <h2>Heading 2</h2> + <h2>Heading 2</h2> + <h2>Heading 2</h2> + + <h3>Heading 3</h3> + <h3>Heading 3</h3> + <h3>Heading 3</h3> + + <h4>Heading 4</h4> + <h4>Heading 4</h4> + <h4>Heading 4</h4> + + <div class="💩" id="💩" 💩="💩"></div> +</body> +</html>
\ No newline at end of file diff --git a/devtools/server/tests/mochitest/inspector-styles-data.css b/devtools/server/tests/mochitest/inspector-styles-data.css new file mode 100644 index 000000000..5c3652f52 --- /dev/null +++ b/devtools/server/tests/mochitest/inspector-styles-data.css @@ -0,0 +1,3 @@ +.external-rule { + cursor: crosshair; +} diff --git a/devtools/server/tests/mochitest/inspector-styles-data.html b/devtools/server/tests/mochitest/inspector-styles-data.html new file mode 100644 index 000000000..a2a126f0e --- /dev/null +++ b/devtools/server/tests/mochitest/inspector-styles-data.html @@ -0,0 +1,81 @@ +<html> +<script> + window.onload = () => { + window.opener.postMessage('ready', '*') + } +</script> +<style> + .inheritable-rule { + font-size: 15px; + } + .uninheritable-rule { + background-color: #f06; + } + @media screen { + #mediaqueried { + background-color: #f06; + } + } + #svgcontent rect { + fill: rgb(1,2,3); + } + + #layout-element, + #layout-auto-margin-element { + width: 50px; + height: 50px; + padding: 3px 5px 7px 5px; + border: 5px solid red; + margin: 10px 20px 30px 0; + box-sizing: border-box; + position: absolute; + z-index: 2; + } + + #layout-auto-margin-element { + margin: 10px auto; + } +</style> +<link type="text/css" rel="stylesheet" href="inspector-styles-data.css"></link> +<body> + <h1>Style Actor Tests</h1> + <!-- Inheritance checks --> + <div id="inheritable-rule-uninheritable-style" class="inheritable-rule" style="background-color: purple"> + <div id="inheritable-rule-inheritable-style" class="inheritable-rule" style="color: blue"> + <div id="uninheritable-rule-uninheritable-style" class="uninheritable-rule" style="background-color: green"> + <div id="uninheritable-rule-inheritable-style" class="uninheritable-rule" style="color: red"> + <div id="test-node"> + Here is the test node. + </div> + </div> + </div> + </div> + </div> + + <!-- Computed checks --> + <div id="computed-parent" class="external-rule inheritable-rule uninheritable-rule" style="color: red;"> + <div id="computed-test-node" class="external-rule"> + Here is the test node. + </div> + </div> + + <!-- Matched checks --> + <div id="matched-parent" class="external-rule inheritable-rule uninheritable-rule" style="color: red;"> + <div id="matched-test-node" style="font-size: 10px" class="external-rule"> + Here is the test node. + </div> + </div> + + <div id="mediaqueried"> + Screen mediaqueried. + </div> + + <div id="svgcontent"> + <svg><rect></rect></svg> + </div> + + <div id="layout-element">I can has layout</div> + <div id="layout-auto-margin-element">I can has layout too</div> + +</body> +</html> diff --git a/devtools/server/tests/mochitest/inspector-traversal-data.html b/devtools/server/tests/mochitest/inspector-traversal-data.html new file mode 100644 index 000000000..45b8c2ede --- /dev/null +++ b/devtools/server/tests/mochitest/inspector-traversal-data.html @@ -0,0 +1,90 @@ +<html> +<head> + <meta charset="UTF-8"> + <title>Inspector Traversal Test Data</title> + <style type="text/css"> + #pseudo::before { + content: "before"; + } + #pseudo::after { + content: "after"; + } + #pseudo-empty::before { + content: "before an empty element"; + } + #shadow::before { + content: "Testing ::before on a shadow host"; + } + </style> + <script type="text/javascript"> + window.onload = function() { + + // Set up a basic shadow DOM + var host = document.querySelector('#shadow'); + if (host.createShadowRoot) { + var root = host.createShadowRoot(); + root.innerHTML = '<h3>Shadow <em>DOM</em></h3><select multiple></select>'; + } + + // Put a copy of the body in an iframe to test frame traversal. + var body = document.querySelector("body"); + var data = "data:text/html,<html>" + body.outerHTML + "<html>"; + var iframe = document.createElement("iframe"); + iframe.setAttribute("id", "childFrame"); + iframe.onload = function() { + window.opener.postMessage('ready', '*') + }; + iframe.src = data; + body.appendChild(iframe); + } + </script> +</head> +<body style="background-color:white"> + <h1>Inspector Actor Tests</h1> + <span id="longstring">longlonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglong</span> + <span id="shortstring">short</span> + <span id="empty"></span> + <div id="longlist" data-test="exists"> + <div id="a">a</div> + <div id="b">b</div> + <div id="c">c</div> + <div id="d">d</div> + <div id="e">e</div> + <div id="f">f</div> + <div id="g">g</div> + <div id="h">h</div> + <div id="i">i</div> + <div id="j">j</div> + <div id="k">k</div> + <div id="l">l</div> + <div id="m">m</div> + <div id="n">n</div> + <div id="o">o</div> + <div id="p">p</div> + <div id="q">q</div> + <div id="r">r</div> + <div id="s">s</div> + <div id="t">t</div> + <div id="u">u</div> + <div id="v">v</div> + <div id="w">w</div> + <div id="x">x</div> + <div id="y">y</div> + <div id="z">z</div> + </div> + <div id="longlist-sibling"> + <div id="longlist-sibling-firstchild"></div> + </div> + <p id="edit-html"></p> + + <select multiple><option>one</option><option>two</option></select> + <div id="pseudo"><span>middle</span></div> + <div id="pseudo-empty"></div> + <div id="shadow">light dom</div> + <object> + <div id="1"></div> + </object> + <div class="node-to-duplicate"></div> + <div id="scroll-into-view" style="margin-top: 1000px;">scroll</div> +</body> +</html> diff --git a/devtools/server/tests/mochitest/inspector_css-properties.html b/devtools/server/tests/mochitest/inspector_css-properties.html new file mode 100644 index 000000000..2c160c928 --- /dev/null +++ b/devtools/server/tests/mochitest/inspector_css-properties.html @@ -0,0 +1,10 @@ +<html> +<head> +<body> + <script type="text/javascript"> + window.onload = function() { + window.opener.postMessage('ready', '*'); + }; + </script> +</body> +</html> diff --git a/devtools/server/tests/mochitest/inspector_getImageData.html b/devtools/server/tests/mochitest/inspector_getImageData.html new file mode 100644 index 000000000..eebecea90 --- /dev/null +++ b/devtools/server/tests/mochitest/inspector_getImageData.html @@ -0,0 +1,21 @@ +<html> +<head> +<body> + <img class="custom"> + <img class="big-horizontal" src="large-image.jpg" style="width:500px;"> + <canvas class="big-vertical" style="width:500px;"></canvas> + <img class="small" src="small-image.gif"> + <img class="data" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII="> + <script> + window.onload = () => { + var canvas = document.querySelector("canvas"), ctx = canvas.getContext("2d"); + canvas.width = 1000; + canvas.height = 2000; + ctx.fillStyle = "red"; + ctx.fillRect(0, 0, 1000, 2000); + + window.opener.postMessage('ready', '*') + } + </script> +</body> +</html> diff --git a/devtools/server/tests/mochitest/large-image.jpg b/devtools/server/tests/mochitest/large-image.jpg Binary files differnew file mode 100644 index 000000000..bda383e59 --- /dev/null +++ b/devtools/server/tests/mochitest/large-image.jpg diff --git a/devtools/server/tests/mochitest/memory-helpers.js b/devtools/server/tests/mochitest/memory-helpers.js new file mode 100644 index 000000000..aea8c4732 --- /dev/null +++ b/devtools/server/tests/mochitest/memory-helpers.js @@ -0,0 +1,52 @@ +var Cu = Components.utils; +var Cc = Components.classes; +var Ci = Components.interfaces; + +var { require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const { Task } = require("devtools/shared/task"); +var Services = require("Services"); +var { DebuggerClient } = require("devtools/shared/client/main"); +var { DebuggerServer } = require("devtools/server/main"); + +var { MemoryFront } = require("devtools/shared/fronts/memory"); + +// Always log packets when running tests. +Services.prefs.setBoolPref("devtools.debugger.log", true); +SimpleTest.registerCleanupFunction(function () { + Services.prefs.clearUserPref("devtools.debugger.log"); +}); + +function startServerAndGetSelectedTabMemory() { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + var client = new DebuggerClient(DebuggerServer.connectPipe()); + + return client.connect() + .then(() => client.listTabs()) + .then(response => { + var form = response.tabs[response.selected]; + var memory = MemoryFront(client, form, response); + + return { memory, client }; + }); +} + +function destroyServerAndFinish(client) { + client.close().then(() => { + DebuggerServer.destroy(); + SimpleTest.finish(); + }); +} + +function waitForTime(ms) { + return new Promise((resolve, reject) => { + setTimeout(resolve, ms); + }); +} + +function waitUntil(predicate) { + if (predicate()) { + return Promise.resolve(true); + } + return new Promise(resolve => setTimeout(() => waitUntil(predicate).then(() => resolve(true)), 10)); +} diff --git a/devtools/server/tests/mochitest/nonchrome_unsafeDereference.html b/devtools/server/tests/mochitest/nonchrome_unsafeDereference.html new file mode 100644 index 000000000..6c19d3104 --- /dev/null +++ b/devtools/server/tests/mochitest/nonchrome_unsafeDereference.html @@ -0,0 +1,8 @@ +<!DOCTYPE HTML> +<html> +<script> +var xhr = new XMLHttpRequest; +xhr.timeout = 1742; +xhr.expando = 'Expando!'; +</script> +</html> diff --git a/devtools/server/tests/mochitest/setup-in-child.js b/devtools/server/tests/mochitest/setup-in-child.js new file mode 100644 index 000000000..a575faa20 --- /dev/null +++ b/devtools/server/tests/mochitest/setup-in-child.js @@ -0,0 +1,20 @@ +const {Cc, Ci} = require("chrome"); +const cpmm = Cc["@mozilla.org/childprocessmessagemanager;1"]. + getService(Ci.nsIMessageListenerManager); +const { DebuggerServer } = require("devtools/server/main"); + +exports.setupChild = function (a, b, c) { + cpmm.sendAsyncMessage("test:setupChild", [a, b, c]); +}; + +exports.callParent = function () { + // Hack! Fetch DebuggerServerConnection objects directly within DebuggerServer guts. + for (let id in DebuggerServer._connections) { + let conn = DebuggerServer._connections[id]; + conn.setupInParent({ + module: "chrome://mochitests/content/chrome/devtools/server/tests/mochitest/setup-in-parent.js", + setupParent: "setupParent", + args: [{one: true}, 2, "three"] + }); + } +}; diff --git a/devtools/server/tests/mochitest/setup-in-parent.js b/devtools/server/tests/mochitest/setup-in-parent.js new file mode 100644 index 000000000..9845ee647 --- /dev/null +++ b/devtools/server/tests/mochitest/setup-in-parent.js @@ -0,0 +1,10 @@ +var {Ci} = require("chrome"); +var Services = require("Services"); + +exports.setupParent = function ({mm, prefix}) { + let args = [ + !!mm.QueryInterface(Ci.nsIMessageSender), + prefix + ]; + Services.obs.notifyObservers(null, "test:setupParent", JSON.stringify(args)); +}; diff --git a/devtools/server/tests/mochitest/small-image.gif b/devtools/server/tests/mochitest/small-image.gif Binary files differnew file mode 100644 index 000000000..e702427a5 --- /dev/null +++ b/devtools/server/tests/mochitest/small-image.gif diff --git a/devtools/server/tests/mochitest/test_Debugger.Script.prototype.global.html b/devtools/server/tests/mochitest/test_Debugger.Script.prototype.global.html new file mode 100644 index 000000000..77357e608 --- /dev/null +++ b/devtools/server/tests/mochitest/test_Debugger.Script.prototype.global.html @@ -0,0 +1,48 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=958646 + +Debugger.Script.prototype.global should return innerize globals, not WindowProxies. +--> +<head> + <meta charset="utf-8"> + <title>Debugger.Script.prototype.global should return inner windows</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +Components.utils.import("resource://gre/modules/jsdebugger.jsm"); +addDebuggerToGlobal(this); + +window.onload = function () { + SimpleTest.waitForExplicitFinish(); + + var iframe = document.createElement("iframe"); + iframe.src = "data:text/html,<script>function glorp() { }<\/script>"; + iframe.onload = firstOnLoadHandler; + document.body.appendChild(iframe); + + function firstOnLoadHandler() { + var dbg = new Debugger; + var iframeDO = dbg.addDebuggee(iframe.contentWindow); + + // For sanity: check that the debuggee global is the inner window, + // and that the outer window gets a distinct D.O. + var iframeWindowProxyDO = iframeDO.makeDebuggeeValue(iframe.contentWindow); + ok(iframeDO !== iframeWindowProxyDO); + + // The real test: Debugger.Script.prototype.global returns inner windows. + ok(iframeDO.getOwnPropertyDescriptor('glorp').value.script.global === iframeDO); + + SimpleTest.finish(); + } +} + +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_Debugger.Source.prototype.element.html b/devtools/server/tests/mochitest/test_Debugger.Source.prototype.element.html new file mode 100644 index 000000000..3cbc22353 --- /dev/null +++ b/devtools/server/tests/mochitest/test_Debugger.Source.prototype.element.html @@ -0,0 +1,182 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=941876 + +Debugger.Source.prototype.element and .elementAttributeName should report the DOM +element to which code is attached (if any), and how. +--> +<head> + <meta charset="utf-8"> + <title>Debugger.Source.prototype.element should return owning element</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +Components.utils.import("resource://gre/modules/jsdebugger.jsm"); +addDebuggerToGlobal(this); + +window.onload = function () { + SimpleTest.waitForExplicitFinish(); + + var log = ''; + var doc, dieter, ulrich, isolde, albrecht; + var dbg, iframeDO, DOFor; + + // Create an iframe to debug. + // We can't use a data: URL here, because we want to test script elements + // that refer to the JavaScript via 'src' attributes, and data: documents + // can't refer to those. So we use a separate HTML document. + var iframe = document.createElement("iframe"); + iframe.src = "Debugger.Source.prototype.element.html"; + iframe.onload = onLoadHandler; + document.body.appendChild(iframe); + + function onLoadHandler() { + log += 'l'; + + // Now that the iframe's window has been created, we can add + // it as a debuggee. + dbg = new Debugger; + dbg.onDebuggerStatement = franzDebuggerHandler; + iframeDO = dbg.addDebuggee(iframe.contentWindow); + DOFor = iframeDO.makeDebuggeeValue.bind(iframeDO); + + // Send a click event to heidi. + doc = iframe.contentWindow.document; + doc.getElementById('heidi').dispatchEvent(new Event('click')); + } + + function franzDebuggerHandler(frame) { + log += 'f'; + + // The top stack frame should be franz, belonging to the script element. + ok(frame.callee.displayName === 'franz', 'top frame is franz'); + ok(frame.script.source.element === DOFor(doc.getElementById('franz')), + 'top frame source belongs to element franz'); + ok(frame.script.source.elementAttributeName === undefined, + "top frame source doesn't belong to an attribute"); + + // The second stack frame should belong to heinrich. + ok(frame.older.script.source.element === DOFor(doc.getElementById('heinrich')), + "second frame source belongs to element heinrich"); + ok(frame.older.script.source.elementAttributeName === undefined, + "second frame source doesn't belong to an attribute"); + + // The next stack frame should belong to heidi's onclick handler. + ok(frame.older.older.script.source.element === DOFor(doc.getElementById('heidi')), + 'third frame source belongs to element heidi'); + ok(frame.older.older.script.source.elementAttributeName === 'onclick', + "third frame source belongs to 'onclick' attribute"); + + // Try a dynamically inserted inline script element. + ulrich = doc.createElement('script'); + ulrich.text = 'debugger;' + dbg.onDebuggerStatement = ulrichDebuggerHandler; + doc.body.appendChild(ulrich); + } + + function ulrichDebuggerHandler(frame) { + log += 'u'; + + // The top frame should be ulrich's text. + ok(frame.script.source.element === DOFor(ulrich), + "top frame belongs to ulrich"); + ok(frame.script.source.elementAttributeName === undefined, + "top frame is not on an attribute of ulrich"); + + // Try a dynamically inserted out-of-line script element. + isolde = doc.createElement('script'); + isolde.setAttribute('src', 'Debugger.Source.prototype.element-2.js'); + isolde.setAttribute('id', 'idolde, my dear'); + dbg.onDebuggerStatement = isoldeDebuggerHandler; + doc.body.appendChild(isolde); + } + + function isoldeDebuggerHandler(frame) { + log += 'i'; + + // The top frame should belong to isolde. + ok(frame.script.source.element === DOFor(isolde), + "top frame belongs to isolde"); + info("frame.script.source.element is: " + uneval(frame.script.source.element)); + if (typeof frame.script.source.element.unsafeDereference() == 'object') { + info(" toString: " + frame.script.source.element.unsafeDereference()); + info(" id: " + frame.script.source.element.unsafeDereference().id); + } + + ok(frame.script.source.elementAttributeName === undefined, + "top frame source is not an attribute of isolde"); + info("frame.script.source.elementAttributeName is: " + + uneval(frame.script.source.elementAttributeName)); + + // Try a dynamically created div element with a handler. + dieter = doc.createElement('div'); + dieter.setAttribute('id', 'dieter'); + dieter.setAttribute('ondrag', 'debugger;'); + dbg.onDebuggerStatement = dieterDebuggerHandler; + dieter.dispatchEvent(new Event('drag')); + } + + function dieterDebuggerHandler(frame) { + log += 'd'; + + // The top frame should belong to dieter's ondrag handler. + ok(frame.script.source.element === DOFor(dieter), + "second event's handler belongs to dieter"); + ok(frame.script.source.elementAttributeName === 'ondrag', + "second event's handler is on dieter's 'ondrag' element"); + + // Try sending an 'onresize' event to the window. + // + // Note that we only want Debugger to see the events we send, not any + // genuine resize events accidentally generated by the test harness (see bug + // 1162067). So we mark our events as cancelable; that seems to be the only + // bit chrome can fiddle on an Event that content code will see and that + // won't affect propagation. Then, the content event only runs its + // 'debugger' statement when the event is cancelable. It's a kludge. + dbg.onDebuggerStatement = resizeDebuggerHandler; + iframe.contentWindow.dispatchEvent(new Event('resize', { cancelable: true })); + } + + function resizeDebuggerHandler(frame) { + log += 'e'; + + // The top frame should belong to the body's 'onresize' handler, even + // though we sent the message to the window and it was handled. + ok(frame.script.source.element === DOFor(doc.body), + "onresize event handler belongs to body element"); + ok(frame.script.source.elementAttributeName === 'onresize', + "onresize event handler is on body element's 'onresize' attribute"); + + // In SVG, the event and the attribute that holds that event's handler + // have different names. Debugger.Source.prototype.elementAttributeName + // should report (as one might infer) the attribute name, not the event + // name. + albrecht = doc.createElementNS('http://www.w3.org/2000/svg', 'svg'); + albrecht.setAttribute('onload', 'debugger;'); + dbg.onDebuggerStatement = SVGLoadHandler; + albrecht.dispatchEvent(new Event("SVGLoad")); + } + + function SVGLoadHandler(frame) { + log += 's'; + + // The top frame's source should be on albrecht's 'onload' attribute. + ok(frame.script.source.element === DOFor(albrecht), + "SVGLoad event handler belongs to albrecht"); + ok(frame.script.source.elementAttributeName === 'onload', + "SVGLoad event handler is on albrecht's 'onload' attribute"); + + ok(log === 'lfuides', "all tests actually ran"); + SimpleTest.finish(); + } +} + +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_Debugger.Source.prototype.introductionScript.html b/devtools/server/tests/mochitest/test_Debugger.Source.prototype.introductionScript.html new file mode 100644 index 000000000..cbc2ae615 --- /dev/null +++ b/devtools/server/tests/mochitest/test_Debugger.Source.prototype.introductionScript.html @@ -0,0 +1,97 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=969786 + +Debugger.Source.prototype.introductionScript and .introductionOffset should +behave when 'eval' is called with no scripted frames active at all. +--> +<head> + <meta charset="utf-8"> + <title>Debugger.Source.prototype.introductionScript with no caller</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +Components.utils.import("resource://gre/modules/jsdebugger.jsm"); +addDebuggerToGlobal(this); + +window.onload = function () { + SimpleTest.waitForExplicitFinish(); + + var dbg, iframeDO, doc, script2DO; + + // Create an iframe to debug. + var iframe = document.createElement("iframe"); + iframe.src = "data:text/html,<div>Hi!</div>"; + iframe.onload = onLoadHandler; + document.body.appendChild(iframe); + + function onLoadHandler() { + // Now that the iframe's window has been created, we can add + // it as a debuggee. + dbg = new Debugger; + iframeDO = dbg.addDebuggee(iframe.contentWindow); + + doc = iframe.contentWindow.document; + var script = doc.createElement('script'); + script.text = "setTimeout(eval.bind(null, 'debugger;'), 0);"; + dbg.onDebuggerStatement = timerHandler; + doc.body.appendChild(script); + } + + function timerHandler(frame) { + // The top stack frame's source should have an undefined + // introduction script and introduction offset. + var source = frame.script.source; + ok(source.introductionScript === undefined, + "setTimeout eval introductionScript is undefined"); + ok(source.introductionOffset === undefined, + "setTimeout eval introductionOffset is undefined"); + + // Check that the above isn't just some quirk of iframes, or the + // browser milieu destroying information: an eval script should indeed + // have proper introduction information. + var script2 = doc.createElement('script'); + script2.text = "eval('debugger;');"; + script2DO = iframeDO.makeDebuggeeValue(script2); + dbg.onDebuggerStatement = evalHandler; + doc.body.appendChild(script2); + } + + function evalHandler(frame) { + // The top stack frame's source should be introduced by the script that + // called eval. + var source = frame.script.source; + var frame2 = frame.older; + + ok(source.introductionType === 'eval', + "top frame's source was introduced by 'eval'"); + ok(source.introductionScript === frame2.script, + "eval frame's introduction script is the older frame's script"); + ok(source.introductionOffset === frame2.offset, + "eval frame's introduction offset is current offset in older frame"); + ok(source.introductionScript.source.element === script2DO, + "eval frame's introducer belongs to script2 element"); + + // The frame that called eval, in turn, should have no introduction + // information. (In the future, we certainly could point at the call + // that inserted the script element into the document; if that happens, + // we can update this test.) + ok(frame2.script.source.introductionType === 'scriptElement', + "older frame has no introduction type"); + ok(frame2.script.source.introductionScript === undefined, + "older frame has no introduction script"); + ok(frame2.script.source.introductionOffset === undefined, + "older frame has no introduction offset"); + + SimpleTest.finish(); + } +} +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_Debugger.Source.prototype.introductionType.html b/devtools/server/tests/mochitest/test_Debugger.Source.prototype.introductionType.html new file mode 100644 index 000000000..c0066659c --- /dev/null +++ b/devtools/server/tests/mochitest/test_Debugger.Source.prototype.introductionType.html @@ -0,0 +1,181 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=935203 + +Debugger.Source.prototype.introductionType should return 'eventHandler' for +JavaScrip appearing in an inline event handler attribute. +--> +<head> + <meta charset="utf-8"> + <title>Debugger.Source.prototype.introductionType should identify event handlers</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +Components.utils.import("resource://gre/modules/jsdebugger.jsm"); +addDebuggerToGlobal(this); + +var dbg; +var iframeDO, doc; +var Tootles, TootlesDO; + +window.onload = function () { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +}; + +addTest(function setup() { + // Create an iframe to debug. + var iframe = document.createElement("iframe"); + iframe.src = "data:text/html," + + "<div id='Tootles' onclick='debugger;'>I'm a DIV!</div>" + + "<script id='Auddie'>function auddie() { debugger; }<\/script>"; + iframe.onload = onLoadHandler; + document.body.appendChild(iframe); + + function onLoadHandler() { + // Now that the iframe's window has been created, we can add + // it as a debuggee. + dbg = new Debugger; + iframeDO = dbg.addDebuggee(iframe.contentWindow); + doc = iframe.contentWindow.document; + Tootles = doc.getElementById('Tootles'); + TootlesDO = iframeDO.makeDebuggeeValue(Tootles); + + runNextTest(); + } +}); + + +// Check the introduction type of in-markup event handler code. +// Send a click event to Tootles, whose handler has a 'debugger' statement, +// and check that script's introduction type. +addTest(function ClickOnTootles() { + dbg.onDebuggerStatement = TootlesClickDebugger; + Tootles.dispatchEvent(new Event('click')); + + function TootlesClickDebugger(frame) { + // some sanity checks + ok(frame.script.source.element === TootlesDO, + "top frame source belongs to element 'Tootles'"); + is(frame.script.source.elementAttributeName, 'onclick', + "top frame source belongs to 'onclick' attribute"); + + // And, the actual point of this test: + is(frame.script.source.introductionType, 'eventHandler', + "top frame source's introductionType is 'eventHandler'"); + + runNextTest(); + } +}); + + +// Check the introduction type of dynamically added event handler code. +// Add a drag event handler to Tootles as a string, and then send +// Tootles a drag event. +addTest(function DragTootles() { + dbg.onDebuggerStatement = TootlesDragDebugger; + Tootles.setAttribute('ondrag', 'debugger;'); + Tootles.dispatchEvent(new Event('drag')); + + function TootlesDragDebugger(frame) { + // sanity checks + ok(frame.script.source.element === TootlesDO, + "top frame source belongs to element 'Tootles'"); + is(frame.script.source.elementAttributeName, 'ondrag', + "top frame source belongs to 'ondrag' attribute"); + + // And, the actual point of this test: + is(frame.script.source.introductionType, 'eventHandler', + "top frame source's introductionType is 'eventHandler'"); + + runNextTest(); + } +}); + + +// Check the introduction type of an in-markup script element. +addTest(function checkAuddie() { + var fnDO = iframeDO.getOwnPropertyDescriptor('auddie').value; + var AuddieDO = iframeDO.makeDebuggeeValue(doc.getElementById('Auddie')); + + is(fnDO.class, 'Function', + "Script element 'Auddie' defined function 'auddie'."); + ok(fnDO.script.source.element === AuddieDO, + "Function auddie's script belongs to script element 'Auddie'"); + is(fnDO.script.source.elementAttributeName, undefined, + "Function auddie's script doesn't belong to any attribute of 'Auddie'"); + is(fnDO.script.source.introductionType, 'scriptElement', + "Function auddie's script's source was introduced by a script element"); + + runNextTest(); +}); + + +// Check the introduction type of a dynamically inserted script element. +addTest(function InsertRover() { + dbg.onDebuggerStatement = RoverDebugger; + var rover = doc.createElement('script'); + var roverDO = iframeDO.makeDebuggeeValue(rover); + rover.text = 'debugger;'; + doc.body.appendChild(rover); + + function RoverDebugger(frame) { + // sanity checks + ok(frame.script.source.element === roverDO, + "Rover script belongs to Rover"); + ok(frame.script.source.elementAttributeName === undefined, + "Rover script doesn't belong to an attribute of Rover"); + + // Check the introduction type. + ok(frame.script.source.introductionType === 'scriptElement', + "Rover script's introduction type is 'scriptElement'"); + + runNextTest(); + } +}); + + +// Create a XUL document with a script element, and check its introduction type. +addTest(function XULDocumentScript() { + var xulFrame = document.createElement('iframe'); + xulFrame.src = "data:application/vnd.mozilla.xul+xml;charset=utf-8," + + "<?xml version=\"1.0\"?>" + + "<window xmlns='http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'>" + + "<script id='xulie'>function xulScriptFunc() { debugger; }<\/script>" + + "</window>"; + xulFrame.onload = xulLoaded; + info("Appending iframe containing XUL document"); + document.body.appendChild(xulFrame); + + function xulLoaded() { + info("Loaded XUL document"); + var xulFrameDO = dbg.addDebuggee(xulFrame.contentWindow); + var xulDoc = xulFrame.contentWindow.document; + var xulieDO = xulFrameDO.makeDebuggeeValue(xulDoc.getElementById('xulie')); + var xulFnDO = xulFrameDO.getOwnPropertyDescriptor('xulScriptFunc').value; + is(typeof xulFnDO, 'object', "XUL script element defined 'xulScriptFunc'"); + is(xulFnDO.class, 'Function', + "XUL global 'xulScriptFunc' is indeed a function"); + + // A XUL document's script elements' code gets shared amongst all + // instantiations of the document, so there's no specific DOM element + // we can attribute the code to. + is(xulFnDO.script.source.element, undefined, + "XUL script code should not be attributed to any individual element"); + + is(xulFnDO.script.source.introductionType, 'scriptElement', + "xulScriptFunc's introduction type is 'scriptElement'"); + runNextTest(); + } +}); + +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_animation_actor-lifetime.html b/devtools/server/tests/mochitest/test_animation_actor-lifetime.html new file mode 100644 index 000000000..a5265d918 --- /dev/null +++ b/devtools/server/tests/mochitest/test_animation_actor-lifetime.html @@ -0,0 +1,91 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=1247243 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 1247243</title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +window.onload = function() { + const Ci = Components.interfaces; + const {AnimationsFront} = require("devtools/shared/fronts/animation"); + const {InspectorFront} = require("devtools/shared/fronts/inspector"); + + SimpleTest.waitForExplicitFinish(); + + let gWalker = null; + let gClient = null; + let animationsFront = null; + + addTest(function setup() { + info ("Setting up inspector and animation actors."); + + let url = document.getElementById("animationContent").href; + attachURL(url, function(err, client, tab, doc) { + let inspector = InspectorFront(client, tab); + + animationsFront = new AnimationsFront(client, tab); + + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + }).then(runNextTest)); + + }); + }); + + addAsyncTest(function* testActorLifetime() { + + info ("Testing animated node actor"); + let animatedNodeActor = yield gWalker.querySelector(gWalker.rootNode, + ".animated"); + yield animationsFront.getAnimationPlayersForNode(animatedNodeActor); + + let animationsActor = DebuggerServer._searchAllConnectionsForActor(animationsFront.actorID); + + is(animationsActor.actors.length, 1, + "AnimationActor have 1 AnimationPlayerActors"); + + info ("Testing AnimationPlayerActors release"); + let stillNodeActor = yield gWalker.querySelector(gWalker.rootNode, + ".still"); + yield animationsFront.getAnimationPlayersForNode(stillNodeActor); + is(animationsActor.actors.length, 0, + "AnimationActor does not have any AnimationPlayerActors anymore"); + + info ("Testing multi animated node actor"); + let multiNodeActor = yield gWalker.querySelector(gWalker.rootNode, + ".multi"); + yield animationsFront.getAnimationPlayersForNode(multiNodeActor); + is(animationsActor.actors.length, 2, + "AnimationActor has now 2 AnimationPlayerActors"); + + info ("Testing single animated node actor"); + yield animationsFront.getAnimationPlayersForNode(animatedNodeActor); + is(animationsActor.actors.length, 1, + "AnimationActor has only one AnimationPlayerActors"); + + info ("Testing AnimationPlayerActors release again"); + yield animationsFront.getAnimationPlayersForNode(stillNodeActor); + is(animationsActor.actors.length, 0, + "AnimationActor does not have any AnimationPlayerActors anymore"); + + runNextTest(); + }); + + + runNextTest(); +}; + </script> +</head> +<body> + <a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1247243">Mozilla Bug 1247243</a> + <a id="animationContent" target="_blank" href="animation-data.html">Test Document</a> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_connectToChild.html b/devtools/server/tests/mochitest/test_connectToChild.html new file mode 100644 index 000000000..3bda7b566 --- /dev/null +++ b/devtools/server/tests/mochitest/test_connectToChild.html @@ -0,0 +1,134 @@ +<SDOCTYPv HTM.> +<html> +<!-- +Bug 966991 - Test DebuggerServer.connectToChild +--> +<head> + <meta charset="utf-8"> + <title>Mozilla Bug</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script type="application/javascript;version=1.8"> + +let Cu = Components.utils; +let Cc = Components.classes; +let Ci = Components.interfaces; + +let { require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); +let { DebuggerClient } = require("devtools/shared/client/main"); +let { DebuggerServer } = require("devtools/server/main"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + SpecialPowers.pushPrefEnv({ + "set": [ + // Always log packets when running tests. + ["devtools.debugger.log", true], + ["dom.mozBrowserFramesEnabled", true] + ] + }, runTests); +} + +function runTests() { + // Create a minimal iframe with a message manager + let iframe = document.createElement("iframe"); + iframe.mozbrowser = true; + document.body.appendChild(iframe); + + let mm = iframe.QueryInterface(Ci.nsIFrameLoaderOwner).frameLoader.messageManager; + + // Register a test actor in the child process so that we can know if and when + // this fake actor is disconnected. + mm.loadFrameScript("data:text/javascript,new " + function FrameScriptScope() { + const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); + const { DebuggerServer } = require("devtools/server/main"); + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + } + + function TestActor() {dump("instanciate test actor\n");} + TestActor.prototype = { + actorPrefix: "test", + + disconnect: function () { + sendAsyncMessage("test-actor-disconnected", null); + }, + hello: function () { + return {msg:"world"}; + } + }; + TestActor.prototype.requestTypes = { + "hello": TestActor.prototype.hello + }; + DebuggerServer.addTabActor(TestActor, "testActor"); + }, false); + + // Instantiate a minimal server + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + } + if (!DebuggerServer.createRootActor) { + DebuggerServer.addBrowserActors(); + } + + function firstClient() { + // Fake a first connection to an iframe + let transport = DebuggerServer.connectPipe(); + let conn = transport._serverConnection; + let client = new DebuggerClient(transport); + DebuggerServer.connectToChild(conn, iframe).then(actor => { + ok(actor.testActor, "Got the test actor"); + + // Ensure sending at least one request to our actor, + // otherwise it won't be instanciated, nor be disconnected... + client.request({ + to: actor.testActor, + type: "hello", + }, function (response) { + + // Then close the client. That should end up cleaning our test actor + client.close(); + + // Ensure that our test actor got cleaned up; + // its disconnect method should be called + mm.addMessageListener("test-actor-disconnected", function listener() { + mm.removeMessageListener("test-actor-disconnected", listener); + ok(true, "Actor is cleaned up"); + + secondClient(actor.testActor); + }); + }); + }); + } + + function secondClient(firstActor) { + // Then fake a second one, that should spawn a new set of tab actors + let transport = DebuggerServer.connectPipe(); + let conn = transport._serverConnection; + let client = new DebuggerClient(transport); + DebuggerServer.connectToChild(conn, iframe).then(actor => { + ok(actor.testActor, "Got a test actor for the second connection"); + isnot(actor.testActor, firstActor, "We get different actor instances between two connections"); + + client.close(cleanup); + }); + } + + function cleanup() { + DebuggerServer.destroy(); + iframe.remove(); + SimpleTest.finish() + } + + firstClient(); +} + +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_connection-manager.html b/devtools/server/tests/mochitest/test_connection-manager.html new file mode 100644 index 000000000..bc802f933 --- /dev/null +++ b/devtools/server/tests/mochitest/test_connection-manager.html @@ -0,0 +1,119 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 898485 - [app manager] Implement an abstract connection manager +--> +<head> + <meta charset="utf-8"> + <title>Mozilla Bug</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + var Cu = Components.utils; + + var {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); + var {DebuggerServer} = require("devtools/server/main"); + var Services = require("Services"); + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + + var {ConnectionManager, Connection} = require("devtools/shared/client/connection-manager"); + + var orgCount = ConnectionManager.connections.length; + + ConnectionManager.once("new", (event, c) => { + is(ConnectionManager.connections[orgCount], c, "new event fired, with correct connection"); + }); + + var c1 = ConnectionManager.createConnection(); + var c2 = ConnectionManager.createConnection(); + + is(ConnectionManager.connections[orgCount], c1, "Connection 1 registered"); + is(ConnectionManager.connections[orgCount + 1], c2, "Connection 2 registered"); + + c1.once(Connection.Events.DESTROYED, function() { + is(ConnectionManager.connections.length, orgCount + 1, "Connection 1 destroyed"); + + var c = c2; + + var eventsRef = "connecting connected disconnecting disconnected host-changed disconnected timeout destroyed"; + var events = []; + + var s = Connection.Status; + + is(c.status, s.DISCONNECTED, "disconnected"); + + c.once(Connection.Events.CONNECTING, function(e) { events.push(e); is(c.status, s.CONNECTING, "connecting"); }); + c.once(Connection.Events.CONNECTED, function(e) { events.push(e); is(c.status, s.CONNECTED, "connected"); c.disconnect()}); + c.once(Connection.Events.DISCONNECTING, function(e) { events.push(e); is(c.status, s.DISCONNECTING, "disconnecting"); }); + c.once(Connection.Events.DISCONNECTED, function(e) { events.push(e); is(c.status, s.DISCONNECTED, "disconnected"); testError()}); + c.once(Connection.Events.DESTROYED, function(e) { events.push(e); is(c.status, s.DESTROYED, "destroyed"); finish()}); + + c.connect(); + + function testStore() { + c.store.on("set", function(e,path) { + if (path.join(".") == "device.width") { + is(c.store.object.device.width, window.screen.width, "Store is fed with valid data"); + c.disconnect(); + } + }); + } + + function testError() { + c.once(Connection.Events.DISCONNECTED, function(e) { + events.push(e); + testKeepConnecting(); + }); + c.once(Connection.Events.HOST_CHANGED, function(e) { + events.push(e); + c.connect(); + }); + c.port = 1; + c.host = "localhost"; + } + + function testKeepConnecting() { + // ensure that keepConnecting keep trying connecting + // until the connection attempts timeout + var originalTimeout = Services.prefs.getIntPref("devtools.debugger.remote-timeout"); + Services.prefs.setIntPref("devtools.debugger.remote-timeout", 1000); + c.once("timeout", function (e) { + events.push(e); + Services.prefs.setIntPref("devtools.debugger.remote-timeout", originalTimeout); + ConnectionManager.destroyConnection(c); + }); + c.keepConnecting = true; + var port = ConnectionManager.getFreeTCPPort(); + ok(parseInt(port), "Free TCP port looks like a port number"); + c.port = port; + c.host = "locahost"; + c.connect(); + } + + function finish() { + is(events.join(" "), eventsRef, "Events received in the right order"); + DebuggerServer.destroy(); + SimpleTest.finish(); + } + + }); + + ConnectionManager.destroyConnection(c1); + + +} +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_css-logic-media-queries.html b/devtools/server/tests/mochitest/test_css-logic-media-queries.html new file mode 100644 index 000000000..bc465df55 --- /dev/null +++ b/devtools/server/tests/mochitest/test_css-logic-media-queries.html @@ -0,0 +1,62 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that css-logic handles media-queries correctly +--> +<head> + <meta charset="utf-8"> + <title>Test css-logic media-queries</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <style> + div { + width: 1000px; + height: 100px; + background-color: #f00; + } + + @media screen and (min-width: 1px) { + div { + width: 200px; + } + } + </style> +</head> +<body> + <div></div> + <script type="application/javascript;version=1.8"> + + window.onload = function() { + var { classes: Cc, utils: Cu, interfaces: Ci } = Components; + const DOMUtils = Cc["@mozilla.org/inspector/dom-utils;1"] + .getService(Ci.inIDOMUtils); + + var {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); + var Services = require("Services"); + const {CssLogic} = require("devtools/server/css-logic"); + + SimpleTest.waitForExplicitFinish(); + + let div = document.querySelector("div"); + let cssLogic = new CssLogic(DOMUtils.isInheritedProperty); + cssLogic.highlight(div); + cssLogic.processMatchedSelectors(); + + let _strings = Services.strings + .createBundle("chrome://devtools-shared/locale/styleinspector.properties"); + + let inline = _strings.GetStringFromName("rule.sourceInline"); + + let source1 = inline + ":12"; + let source2 = inline + ":19 @media screen and (min-width: 1px)"; + is(cssLogic._matchedRules[0][0].source, source1, + "rule.source gives correct output for rule 1"); + is(cssLogic._matchedRules[1][0].source, source2, + "rule.source gives correct output for rule 2"); + + SimpleTest.finish(); + } + + </script> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_css-logic-specificity.html b/devtools/server/tests/mochitest/test_css-logic-specificity.html new file mode 100644 index 000000000..45169c1fd --- /dev/null +++ b/devtools/server/tests/mochitest/test_css-logic-specificity.html @@ -0,0 +1,84 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that css-logic calculates CSS specificity properly +--> +<head> + <meta charset="utf-8"> + <title>Test css-logic specificity</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> +</head> +<body style="background:blue;"> + <script type="application/javascript;version=1.8"> + + window.onload = function() { + var {utils: Cu, classes: Cc, interfaces: Ci} = Components; + + const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); + const {CssLogic, CssSelector} = require("devtools/server/css-logic"); + const DOMUtils = Cc["@mozilla.org/inspector/dom-utils;1"] + .getService(Ci.inIDOMUtils); + + const TEST_DATA = [ + {text: "*", expected: 0}, + {text: "LI", expected: 1}, + {text: "UL LI", expected: 2}, + {text: "UL OL + LI", expected: 3}, + {text: "H1 + [REL=\"up\"]", expected: 257}, + {text: "UL OL LI.red", expected: 259}, + {text: "LI.red.level", expected: 513}, + {text: ".red .level", expected: 512}, + {text: "#x34y", expected: 65536}, + {text: "#s12:not(FOO)", expected: 65537}, + {text: "body#home div#warning p.message", expected: 131331}, + {text: "* body#home div#warning p.message", expected: 131331}, + {text: "#footer :not(nav) li", expected: 65538}, + {text: "bar:nth-child(n)", expected: 257}, + {text: "li::-moz-list-number", expected: 1}, + {text: "a:hover", expected: 257} + ]; + + function createDocument() { + let text = TEST_DATA.map(i=>i.text).join(","); + text = '<style type="text/css">' + text + " {color:red;}</style>"; + document.body.innerHTML = text; + } + + function getExpectedSpecificity(selectorText) { + return TEST_DATA.filter(i => i.text === selectorText)[0].expected; + } + + SimpleTest.waitForExplicitFinish(); + + createDocument(); + let cssLogic = new CssLogic(DOMUtils.isInheritedProperty); + + cssLogic.highlight(document.body); + let cssSheet = cssLogic.sheets[0]; + let cssRule = cssSheet.domSheet.cssRules[0]; + let selectors = CssLogic.getSelectors(cssRule); + + info("Iterating over the test selectors") + for (let i = 0; i < selectors.length; i++) { + let selectorText = selectors[i]; + info("Testing selector " + selectorText); + + let selector = new CssSelector(cssRule, selectorText, i); + let expected = getExpectedSpecificity(selectorText); + let specificity = DOMUtils.getSpecificity(selector.cssRule, + selector.selectorIndex) + is(specificity, expected, + 'Selector "' + selectorText + '" has a specificity of ' + expected); + } + + info("Testing specificity of element.style"); + let colorProp = cssLogic.getPropertyInfo("background"); + is(colorProp.matchedSelectors[0].specificity, 0x01000000, + "Element styles have specificity of 0x01000000 (16777216)."); + + SimpleTest.finish(); + } + + </script> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_css-logic.html b/devtools/server/tests/mochitest/test_css-logic.html new file mode 100644 index 000000000..6c21e72c8 --- /dev/null +++ b/devtools/server/tests/mochitest/test_css-logic.html @@ -0,0 +1,167 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const {CssLogic} = require("devtools/server/css-logic"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +addTest(function findAllCssSelectors() { + var nodes = document.querySelectorAll('*'); + for (var i = 0; i < nodes.length; i++) { + var selector = CssLogic.findCssSelector(nodes[i]); + var matches = document.querySelectorAll(selector); + + is(matches.length, 1, 'There is a single match: ' + selector); + is(matches[0], nodes[i], 'The selector matches the correct node: ' + selector); + } + + runNextTest(); +}); + +addTest(function findCssSelectorNotContainedInDocument() { + + var unattached = document.createElement("div"); + unattached.id = "unattached"; + try { + CssLogic.findCssSelector(unattached); + ok (false, "Unattached node did not throw") + } catch(e) { + ok(e, "Unattached node throws an exception"); + } + + var unattachedChild = document.createElement("div"); + unattached.appendChild(unattachedChild); + try { + CssLogic.findCssSelector(unattachedChild); + ok (false, "Unattached child node did not throw") + } catch(e) { + ok(e, "Unattached child node throws an exception"); + } + + var unattachedBody = document.createElement("body"); + try { + CssLogic.findCssSelector(unattachedBody); + ok (false, "Unattached body node did not throw") + } catch(e) { + ok(e, "Unattached body node throws an exception"); + } + + runNextTest(); +}); + +addTest(function findCssSelector() { + + let data = [ + "#one", + "#" + CSS.escape("2"), + ".three", + "." + CSS.escape("4"), + "#find-css-selector > div:nth-child(5)", + "#find-css-selector > p:nth-child(6)", + ".seven", + ".eight", + ".nine", + ".ten", + "div.sameclass:nth-child(11)", + "div.sameclass:nth-child(12)", + "div.sameclass:nth-child(13)", + "#" + CSS.escape("!, \", #, $, %, &, ', (, ), *, +, ,, -, ., /, :, ;, <, =, >, ?, @, [, \\, ], ^, `, {, |, }, ~"), + ]; + + let container = document.querySelector("#find-css-selector"); + is (container.children.length, data.length, "Container has correct number of children."); + + for (let i = 0; i < data.length; i++) { + let node = container.children[i]; + is (CssLogic.findCssSelector(node), data[i], "matched id for index " + (i-1)); + } + + runNextTest(); +}); + +addTest(function getComputedStyle() { + let node = document.querySelector("#computed-style"); + is (CssLogic.getComputedStyle(node).getPropertyValue("width"), + "50px", "Computed style on a normal node works (width)"); + is (CssLogic.getComputedStyle(node).getPropertyValue("height"), + "10px", "Computed style on a normal node works (height)"); + + let firstChild = new _documentWalker(node, window).firstChild(); + is (CssLogic.getComputedStyle(firstChild).getPropertyValue("content"), + "\"before\"", "Computed style on a ::before node works (content)"); + let lastChild = new _documentWalker(node, window).lastChild(); + is (CssLogic.getComputedStyle(lastChild).getPropertyValue("content"), + "\"after\"", "Computed style on a ::after node works (content)"); + + runNextTest(); +}); + +addTest(function getBindingElementAndPseudo() { + let node = document.querySelector("#computed-style"); + var {bindingElement, pseudo} = CssLogic.getBindingElementAndPseudo(node); + + is (bindingElement, node, + "Binding element is the node itself for a normal node"); + ok (!pseudo, "Pseudo is null for a normal node"); + + let firstChild = new _documentWalker(node, window).firstChild(); + var {bindingElement, pseudo} = CssLogic.getBindingElementAndPseudo(firstChild); + is (bindingElement, node, + "Binding element is the parent for a pseudo node"); + is (pseudo, ":before", "Pseudo is correct for a ::before node"); + + let lastChild = new _documentWalker(node, window).lastChild(); + var {bindingElement, pseudo} = CssLogic.getBindingElementAndPseudo(lastChild); + is (bindingElement, node, + "Binding element is the parent for a pseudo node"); + is (pseudo, ":after", "Pseudo is correct for a ::after node"); + + runNextTest(); +}); + + </script> +</head> +<body> + <div id="find-css-selector"> + <div id="one"></div> <!-- Basic ID --> + <div id="2"></div> <!-- Escaped ID --> + <div class="three"></div> <!-- Basic Class --> + <div class="4"></div> <!-- Escaped Class --> + <div attr="5"></div> <!-- Only an attribute --> + <p></p> <!-- Nothing unique --> + <div class="seven seven"></div> <!-- Two classes with same name --> + <div class="eight eight2"></div> <!-- Two classes with different names --> + + <!-- Two elements with the same id - should not use ID --> + <div class="nine" id="nine-and-ten"></div> + <div class="ten" id="nine-and-ten"></div> + + <!-- Three elements with the same id - should use class and nth-child instead --> + <div class="sameclass" id="11-12-13"></div> + <div class="sameclass" id="11-12-13"></div> + <div class="sameclass" id="11-12-13"></div> + + <!-- Special characters --> + <div id="!, ", #, $, %, &, ', (, ), *, +, ,, -, ., /, :, ;, <, =, >, ?, @, [, \, ], ^, `, {, |, }, ~"></div> + </div> + <style type="text/css"> + #computed-style { width: 50px; height: 10px; } + #computed-style::before { content: "before"; } + #computed-style::after { content: "after"; } + </style> + <div id="computed-style"></div> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_css-properties_01.html b/devtools/server/tests/mochitest/test_css-properties_01.html new file mode 100644 index 000000000..45386b830 --- /dev/null +++ b/devtools/server/tests/mochitest/test_css-properties_01.html @@ -0,0 +1,121 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1265798 - Replace inIDOMUtils.cssPropertyIsShorthand +--> +<head> + <meta charset="utf-8"> + <title>Test CSS Properties Actor</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +window.onload = function() { + const { initCssProperties, getCssProperties } = + require("devtools/shared/fronts/css-properties"); + + function promiseAttachUrl (url) { + return new Promise((resolve, reject) => { + attachURL(url, function(err, client, tab, doc) { + if (err) { + return reject(err); + } + resolve({client, tab, doc}); + }); + }) + } + + function toSortedString(array) { + return JSON.stringify(array.sort()); + } + + const runCssPropertiesTests = Task.async(function* (url, useActor) { + info(`Opening two tabs ${useActor ? "with" : "without"} CssPropertiesActor support.`); + + let attachmentA = yield promiseAttachUrl(url); + let attachmentB = yield promiseAttachUrl(url); + + const toolboxMockA = { + target: { + hasActor: () => useActor, + client: attachmentA.client, + form: attachmentA.tab + }, + // Fake the window for css-properties.js's getClientBrowserVersion to work + win: window + }; + const toolboxMockB = { + target: { + hasActor: () => useActor, + client: attachmentB.client, + form: attachmentB.tab + }, + win: window + }; + + yield initCssProperties(toolboxMockA); + yield initCssProperties(toolboxMockB); + + const cssProperties = getCssProperties(toolboxMockA); + const cssPropertiesA = getCssProperties(toolboxMockA); + const cssPropertiesB = getCssProperties(toolboxMockB); + + is(cssProperties, cssPropertiesA, + "Multiple calls with the same toolbox returns the same object."); + isnot(cssProperties, cssPropertiesB, + "Multiple calls with the different toolboxes return different "+ + " objects."); + + ok(cssProperties.isKnown("border"), + "The `border` shorthand property is known."); + ok(cssProperties.isKnown("display"), + "The `display` property is known."); + ok(!cssProperties.isKnown("foobar"), + "A fake property is not known."); + ok(cssProperties.isKnown("--foobar"), + "A CSS variable properly evaluates."); + ok(cssProperties.isKnown("--foob\\{ar"), + "A CSS variable with escaped character properly evaluates."); + ok(cssProperties.isKnown("--fübar"), + "A CSS variable unicode properly evaluates."); + ok(!cssProperties.isKnown("--foo bar"), + "A CSS variable with spaces fails"); + + is(toSortedString(cssProperties.getValues('margin')), + toSortedString(["-moz-calc","auto","calc","inherit","initial","unset"]), + "Can get values for the CSS margin."); + is(cssProperties.getValues('foobar').length, 0, + "Unknown values return an empty array."); + + const bgColorValues = cssProperties.getValues('background-color'); + ok(bgColorValues.includes("blanchedalmond"), + "A property with color values includes blanchedalmond."); + ok(bgColorValues.includes("papayawhip"), + "A property with color values includes papayawhip."); + ok(bgColorValues.includes("rgb"), + "A property with color values includes non-colors."); + + ok(cssProperties.isValidOnClient("margin", "0px", window.document), + "Margin and 0px are valid CSS values"); + ok(!cssProperties.isValidOnClient("margin", "foo", window.document), + "Margin and foo are not valid CSS values"); + }); + + addAsyncTest(function* setup() { + let url = document.getElementById("cssProperties").href; + yield runCssPropertiesTests(url, true); + yield runCssPropertiesTests(url, false); + + runNextTest(); + }); + + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + </script> +</head> +<body> + <a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1265798">Mozilla Bug 1265798</a> + <a id="cssProperties" target="_blank" href="inspector_css-properties.html">Test Document</a> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_css-properties_02.html b/devtools/server/tests/mochitest/test_css-properties_02.html new file mode 100644 index 000000000..1a5d99d72 --- /dev/null +++ b/devtools/server/tests/mochitest/test_css-properties_02.html @@ -0,0 +1,86 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1265798 - Replace inIDOMUtils.cssPropertyIsShorthand +--> +<head> + <meta charset="utf-8"> + <title>Test CSS Properties Actor</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +window.onload = function() { + const { initCssProperties, getCssProperties } = + require("devtools/shared/fronts/css-properties"); + + const { CSS_PROPERTIES_DB } = require("devtools/shared/css/properties-db"); + + function promiseAttachUrl (url) { + return new Promise((resolve, reject) => { + attachURL(url, function(err, client, tab, doc) { + if (err) { + return reject(err); + } + resolve({client, tab, doc}); + }); + }) + } + + addAsyncTest(function* setup() { + let url = document.getElementById("cssProperties").href; + + let attachmentA = yield promiseAttachUrl(url); + let attachmentB = yield promiseAttachUrl(url); + let attachmentC = yield promiseAttachUrl(url); + + const toolboxMatchingVersions = { + target: { + hasActor: () => true, + client: attachmentA.client, + form: attachmentA.tab, + }, + win: window + }; + const toolboxDifferentVersions = { + target: { + hasActor: () => true, + client: attachmentB.client, + form: attachmentB.tab + }, + win: { navigator: { userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:49.0) Gecko/20100101 " + + "Firefox/30.0" }} + }; + + // Modify a property on the static database, to differentiate between a generated + // and static CSS properties database. + CSS_PROPERTIES_DB.properties.color.isStatic = true; + + yield initCssProperties(toolboxMatchingVersions); + yield initCssProperties(toolboxDifferentVersions); + + const cssPropertiesMatching = getCssProperties(toolboxMatchingVersions); + const cssPropertiesDifferent = getCssProperties(toolboxDifferentVersions); + + is(cssPropertiesMatching.properties.color.isStatic, true, + "The static CSS database is used when the client and platform versions match."); + isnot(cssPropertiesDifferent.properties.color.isStatic, undefined, + "The generated CSS database is used when the client and platform versions do " + + "not match, but the client is a Firefox."); + + delete CSS_PROPERTIES_DB.properties.color.isStatic; + + runNextTest(); + }); + + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + </script> +</head> +<body> + <a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1265798">Mozilla Bug 1265798</a> + <a id="cssProperties" target="_blank" href="inspector_css-properties.html">Test Document</a> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_device.html b/devtools/server/tests/mochitest/test_device.html new file mode 100644 index 000000000..d678f185f --- /dev/null +++ b/devtools/server/tests/mochitest/test_device.html @@ -0,0 +1,99 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 895360 - [app manager] Device meta data actor +--> +<head> + <meta charset="utf-8"> + <title>Mozilla Bug</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +window.onload = function() { + var Cu = Components.utils; + var Cc = Components.classes; + var Ci = Components.interfaces; + + Cu.import("resource://gre/modules/PermissionsTable.jsm"); + var {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); + var {DebuggerClient} = require("devtools/shared/client/main"); + var {DebuggerServer} = require("devtools/server/main"); + var Services = require("Services"); + + SimpleTest.waitForExplicitFinish(); + + var {getDeviceFront} = require("devtools/shared/fronts/device"); + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + + var client = new DebuggerClient(DebuggerServer.connectPipe()); + client.connect().then(function onConnect() { + client.listTabs(function onListTabs(aResponse) { + var d = getDeviceFront(client, aResponse); + + var desc, permissions; + var appInfo = Services.appinfo; + var utils = window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + + + var localDesc = { + appid: appInfo.ID, + vendor: appInfo.vendor, + name: appInfo.name, + version: appInfo.version, + appbuildid: appInfo.appBuildID, + platformbuildid: appInfo.platformBuildID, + platformversion: appInfo.platformVersion, + geckobuildid: appInfo.platformBuildID, + geckoversion: appInfo.platformVersion, + useragent: window.navigator.userAgent, + locale: Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry).getSelectedLocale("global"), + os: appInfo.OS, + processor: appInfo.XPCOMABI.split("-")[0], + compiler: appInfo.XPCOMABI.split("-")[1], + dpi: utils.displayDPI, + width: window.screen.width, + height: window.screen.height + } + + function checkValues() { + for (var key in localDesc) { + is(desc[key], localDesc[key], "valid field (" + key + ")"); + } + + var currProfD = Services.dirsvc.get("ProfD", Ci.nsIFile); + var profileDir = currProfD.path; + ok(profileDir.indexOf(desc.profile.length > 0 && desc.profile) != -1, "valid profile name"); + + var a = JSON.stringify(PermissionsTable); + var b = JSON.stringify(permissions.rawPermissionsTable); + + is(a, b, "Permissions Tables is valid"); + + client.close().then(() => { + DebuggerServer.destroy(); + SimpleTest.finish() + }); + } + + + d.getDescription().then((v) => desc = v) + .then(() => d.getRawPermissionsTable()) + .then((json) => permissions = json) + .then(checkValues); + + }); + }); + +} +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_director.html b/devtools/server/tests/mochitest/test_director.html new file mode 100644 index 000000000..ad2648bfa --- /dev/null +++ b/devtools/server/tests/mochitest/test_director.html @@ -0,0 +1,114 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> + <script type="application/javascript;version=1.8" src="./director-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const WAIT_EVENT_TIMEOUT = 3000; + +window.onload = function() { + Task.spawn(function* () { + SimpleTest.waitForExplicitFinish(); + + var tests = [ + runDirectorRegistryActorTest + ].map((testCase) => { + return function* () { + setup(); + yield testCase().then(null, (e) => { + console.error("Exception during testCase run", e); + ok(false, "Exception during testCase run: " + [e, e.fileName, e.lineNumber].join("\n\t")); + }); + + teardown(); + }; + }); + + for (var test of tests) { + yield test(); + } + }).then( + function success() { + SimpleTest.finish() + }, + function error(e) { + console.error("Exception during testCase run", e); + ok(false, "Exception during testCase run: " + [e, e.fileName, e.lineNumber].join("\n\t")); + + SimpleTest.finish(); + } + ); +}; + +var targetWin = null; + +function setup() { + if (!DebuggerServer.initialized) { + DebuggerServer.init(() => true); + DebuggerServer.addBrowserActors(); + + SimpleTest.registerCleanupFunction(teardown); + } +} + +function teardown() { + purgeInstalledDirectorScripts(); + + DebuggerServer.destroy(); + if (targetWin) { + targetWin.close(); + } +} + +/*********************************** + * test cases + **********************************/ + + +function runDirectorRegistryActorTest() { + let testDirectorScriptOptions = { + scriptCode: "(" + (function() { + module.exports = function({port}) { + port.onmessage = function(evt) { + // echo messages + evt.source.postMessage(evt.data); + }; + }; + }).toString() + ")();", + scriptOptions: {} + } + + return Task.spawn(function* () { + let { client, root } = yield newConnectedDebuggerClient(); + + var directorRegistryClient = new DirectorRegistryFront(client, root); + + let installed = yield directorRegistryClient.install("testDirectorScript", testDirectorScriptOptions); + is(installed, true, "DirectorManager.install returns true"); + + let list = yield directorRegistryClient.list(); + is(JSON.stringify(list), JSON.stringify(["testDirectorScript"]), + "DirectorManager.list contains the installed director script"); + + let uninstalled = yield directorRegistryClient.uninstall("testDirectorScript"); + is(uninstalled, true, "DirectorManager.uninstall return true"); + + yield client.close(); + }); +} + + + </script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_director_connectToChild.html b/devtools/server/tests/mochitest/test_director_connectToChild.html new file mode 100644 index 000000000..cb348efe6 --- /dev/null +++ b/devtools/server/tests/mochitest/test_director_connectToChild.html @@ -0,0 +1,98 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> + <script type="application/javascript;version=1.8" src="./director-helpers.js"></script> + <script type="application/javascript;version=1.8"> +window.onload = function() { + Task.spawn(function* () { + SimpleTest.waitForExplicitFinish(); + + var tests = [ + runPropagateDirectorScriptsToChildTest, + ].map((testCase) => { + return function* () { + setup(); + yield testCase().then(null, (e) => { + ok(false, "Exception during testCase run: " + [e, e.fileName, e.lineNumber].join("\n\t")); + }); + + teardown(); + }; + }); + + for (var test of tests) { + yield test(); + } + + SimpleTest.finish(); + }); +}; + +function setup() { + if (!DebuggerServer.initialized) { + DebuggerServer.init(() => true); + DebuggerServer.addBrowserActors(); + SimpleTest.registerCleanupFunction(function() { + DebuggerServer.destroy(); + }); + } +} + +function teardown() { + purgeInstalledDirectorScripts(); + DebuggerServer.destroy(); +} + +/*********************************** + * test cases + **********************************/ + +function runPropagateDirectorScriptsToChildTest() { + let iframe = document.createElement("iframe"); + iframe.mozbrowser = true; + + document.body.appendChild(iframe); + + return Task.spawn(function* () { + var { client, root, transport } = yield newConnectedDebuggerClient(); + + var directorRegistryClient = new DirectorRegistryFront(client, root); + + // install a director script + yield directorRegistryClient.install("testPropagatedDirectorScript", { + scriptCode: "console.log('director script test');", + scriptOptions: {} + }); + + var conn = transport._serverConnection; + var childActor = yield DebuggerServer.connectToChild(conn, iframe); + + ok(typeof childActor.directorManagerActor !== "undefined", + "childActor.directorActor should be defined"); + + var childDirectorManagerClient = new DirectorManagerFront(client, childActor); + + var directorScriptList = yield childDirectorManagerClient.list(); + + ok(directorScriptList.installed.length === 1 && + directorScriptList.installed[0] === "testPropagatedDirectorScript", + "director scripts propagated correctly") + + yield client.close(); + }); +} + </script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_executeInGlobal-outerized_this.html b/devtools/server/tests/mochitest/test_executeInGlobal-outerized_this.html new file mode 100644 index 000000000..8bedde618 --- /dev/null +++ b/devtools/server/tests/mochitest/test_executeInGlobal-outerized_this.html @@ -0,0 +1,69 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=837060 + +When we use Debugger.Object.prototype.executeInGlobal, the 'this' value seen +by the evaluated code should be the WindowProxy, not the inner window +object. +--> +<head> + <meta charset="utf-8"> + <title>Mozilla Bug 837060</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +Components.utils.import("resource://gre/modules/jsdebugger.jsm"); +addDebuggerToGlobal(this); + +window.onload = function () { + SimpleTest.waitForExplicitFinish(); + + var iframe = document.createElement("iframe"); + iframe.src = "data:text/html,<script>var me = 'page 1';<\/script>"; + iframe.onload = firstOnLoadHandler; + document.body.appendChild(iframe); + + function firstOnLoadHandler() { + var dbg = new Debugger; + var page1DO = dbg.addDebuggee(iframe.contentWindow); + iframe.src = "data:text/html,<script>var me = 'page 2';<\/script>"; + iframe.onload = function () { + var page2DO = dbg.addDebuggee(iframe.contentWindow); + ok(page1DO !== page2DO, "the two pages' globals get distinct D.O's"); + ok(page1DO.unsafeDereference() === page2DO.unsafeDereference(), + "unwrapping page1DO and page2DO outerizes both, yielding the same outer window"); + + is(page1DO.executeInGlobal('me').return, 'page 1', "page1DO continues to refer to original page"); + is(page2DO.executeInGlobal('me').return, 'page 2', "page2DO refers to current page"); + + is(page1DO.executeInGlobal('this === window').return, true, + "page 1: Debugger.Object.prototype.executeInGlobal should outerize 'this'"); + is(page1DO.executeInGlobalWithBindings('this === window', {x:2}).return, true, + "page 1: Debugger.Object.prototype.executeInGlobal should outerize 'this'"); + + is(page2DO.executeInGlobal('this === window').return, true, + "page 2: Debugger.Object.prototype.executeInGlobal should outerize 'this'"); + is(page2DO.executeInGlobalWithBindings('this === window', {x:2}).return, true, + "page 2: Debugger.Object.prototype.executeInGlobal should outerize 'this'"); + + // Debugger doesn't let one use outer windows as globals. You have to innerize. + var outerDO = page1DO.makeDebuggeeValue(page1DO.unsafeDereference()); + ok(outerDO !== page1DO, "outer window gets its own D.O, distinct from page 1's global"); + ok(outerDO !== page2DO, "outer window gets its own D.O, distinct from page 2's global"); + SimpleTest.doesThrow(function () { outerDO.executeInGlobal('me'); }, + "outer window D.Os can't be used as globals"); + + SimpleTest.finish(); + } + } +} + +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_framerate_01.html b/devtools/server/tests/mochitest/test_framerate_01.html new file mode 100644 index 000000000..0282d50a2 --- /dev/null +++ b/devtools/server/tests/mochitest/test_framerate_01.html @@ -0,0 +1,141 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1007200 - Create a framerate actor +--> +<head> + <meta charset="utf-8"> + <title>Framerate actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +window.onload = function() { + var Cu = Components.utils; + var Cc = Components.classes; + var Ci = Components.interfaces; + + var { require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); + var Services = require("Services"); + var { DebuggerClient } = require("devtools/shared/client/main"); + var { DebuggerServer } = require("devtools/server/main"); + + // Always log packets when running tests. + Services.prefs.setBoolPref("devtools.debugger.log", true); + SimpleTest.registerCleanupFunction(function() { + Services.prefs.clearUserPref("devtools.debugger.log"); + }); + + SimpleTest.waitForExplicitFinish(); + + var {FramerateFront} = require("devtools/shared/fronts/framerate"); + + function plotFPS(ticks, interval = 100, clamp = 60) { + var timeline = []; + var totalTicks = ticks.length; + + // If the refresh driver didn't get a chance to tick before the + // recording was stopped, assume framerate was 0. + if (totalTicks == 0) { + timeline.push({ delta: 0, value: 0 }); + timeline.push({ delta: interval, value: 0 }); + return timeline; + } + + var frameCount = 0; + var prevTime = ticks[0]; + + for (var i = 1; i < totalTicks; i++) { + var currTime = ticks[i]; + frameCount++; + + var elapsedTime = currTime - prevTime; + if (elapsedTime < interval) { + continue; + } + + var framerate = Math.min(1000 / (elapsedTime / frameCount), clamp); + timeline.push({ delta: prevTime, value: framerate }); + timeline.push({ delta: currTime, value: framerate }); + + frameCount = 0; + prevTime = currTime; + } + + return timeline; + }; + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + + var client = new DebuggerClient(DebuggerServer.connectPipe()); + client.connect().then(function onConnect() { + client.listTabs(function onListTabs(aResponse) { + var form = aResponse.tabs[aResponse.selected]; + var front = FramerateFront(client, form); + + window.setTimeout(() => { + front.startRecording().then(() => { + window.setTimeout(() => { + front.stopRecording().then(rawData => { + onRecordingStopped(front, rawData); + }); + }, 1000); + }); + }, 1000); + }); + }); + + function onRecordingStopped(front, rawData) { + ok(rawData, "There should be a recording available."); + + var timeline = plotFPS(rawData); + ok(timeline.length >= 2, + "There should be at least one measurement available, with two entries."); + + var prevTimeStart = timeline[0].delta; + + for (var i = 0; i < timeline.length; i += 2) { + var currTimeStart = timeline[i].delta; + var currTimeEnd = timeline[i + 1].delta; + info("Testing delta: " + currTimeStart + " vs. " + currTimeEnd); + + ok(currTimeStart < currTimeEnd, + "The start and end time deltas should be consecutive."); + is(currTimeStart, prevTimeStart, + "There should be two time deltas for each framerate value."); + + prevTimeStart = currTimeEnd; + } + + var prevFramerateValue = -1; + + for (var i = 0; i < timeline.length; i += 2) { + var currFramerateStart = timeline[i].value; + var currFramerateEnd = timeline[i + 1].value; + info("Testing framerate: " + currFramerateStart); + + is(currFramerateStart, currFramerateEnd, + "The start and end framerate values should be equal."); + + is(typeof currFramerateStart, "number", "All values should be numbers."); + ok(currFramerateStart <= 60, "All values were correctly clamped.") + + prevFramerateValue = currFramerateStart; + } + + client.close().then(() => { + DebuggerServer.destroy(); + SimpleTest.finish() + }); + } +} +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_framerate_02.html b/devtools/server/tests/mochitest/test_framerate_02.html new file mode 100644 index 000000000..9d4626b12 --- /dev/null +++ b/devtools/server/tests/mochitest/test_framerate_02.html @@ -0,0 +1,113 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1007200 - Create a framerate actor +--> +<head> + <meta charset="utf-8"> + <title>Framerate actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +window.onload = function() { + var Cu = Components.utils; + var Cc = Components.classes; + var Ci = Components.interfaces; + + var {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); + var {DebuggerClient} = require("devtools/shared/client/main"); + var {DebuggerServer} = require("devtools/server/main"); + var Services = require("Services"); + + // Always log packets when running tests. + Services.prefs.setBoolPref("devtools.debugger.log", true); + SimpleTest.registerCleanupFunction(function() { + Services.prefs.clearUserPref("devtools.debugger.log"); + }); + + SimpleTest.waitForExplicitFinish(); + + var {FramerateFront} = require("devtools/shared/fronts/framerate"); + + function plotFPS(ticks, interval = 100, clamp = 60) { + var timeline = []; + var totalTicks = ticks.length; + + // If the refresh driver didn't get a chance to tick before the + // recording was stopped, assume framerate was 0. + if (totalTicks == 0) { + timeline.push({ delta: 0, value: 0 }); + timeline.push({ delta: interval, value: 0 }); + return timeline; + } + + var frameCount = 0; + var prevTime = ticks[0]; + + for (var i = 1; i < totalTicks; i++) { + var currTime = ticks[i]; + frameCount++; + + var elapsedTime = currTime - prevTime; + if (elapsedTime < interval) { + continue; + } + + var framerate = Math.min(1000 / (elapsedTime / frameCount), clamp); + timeline.push({ delta: prevTime, value: framerate }); + timeline.push({ delta: currTime, value: framerate }); + + frameCount = 0; + prevTime = currTime; + } + + return timeline; + }; + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + + var client = new DebuggerClient(DebuggerServer.connectPipe()); + client.connect().then(function onConnect() { + client.listTabs(function onListTabs(aResponse) { + var form = aResponse.tabs[aResponse.selected]; + var front = FramerateFront(client, form); + + front.stopRecording().then(rawData => { + ok(rawData, "There should be a recording available."); + is(rawData.length, 0, "...but it should be empty."); + + var timeline = plotFPS(rawData); + is(timeline.length, 2, + "There should be one measurement plotted, with two entries."); + + info("The framerate should be assumed to be 0 if the recording is empty."); + + is(timeline[0].delta, 0, + "The first time delta should be 0."); + is(timeline[0].value, 0, + "The first framerate value should be 0."); + + is(timeline[1].delta, 100, + "The last time delta should be 100 (the default interval value)."); + is(timeline[1].value, 0, + "The last framerate value should be 0."); + + client.close().then(() => { + DebuggerServer.destroy(); + SimpleTest.finish() + }); + }); + }); + }); +} +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_framerate_03.html b/devtools/server/tests/mochitest/test_framerate_03.html new file mode 100644 index 000000000..da76ebc20 --- /dev/null +++ b/devtools/server/tests/mochitest/test_framerate_03.html @@ -0,0 +1,82 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1023018 - Tests whether or not the framerate actor can handle time ranges. +--> +<head> + <meta charset="utf-8"> + <title>Framerate actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +window.onload = function() { + var Cu = Components.utils; + var Cc = Components.classes; + var Ci = Components.interfaces; + + var {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); + var {DebuggerClient} = require("devtools/shared/client/main"); + var {DebuggerServer} = require("devtools/server/main"); + var Services = require("Services"); + + // Always log packets when running tests. + Services.prefs.setBoolPref("devtools.debugger.log", true); + SimpleTest.registerCleanupFunction(function() { + Services.prefs.clearUserPref("devtools.debugger.log"); + }); + + SimpleTest.waitForExplicitFinish(); + + var {FramerateFront} = require("devtools/shared/fronts/framerate"); + var START_TICK = 2000; + var STOP_TICK = 3000; + var TOTAL_TIME = 5000; + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + + var client = new DebuggerClient(DebuggerServer.connectPipe()); + client.connect().then(function onConnect() { + client.listTabs(function onListTabs(aResponse) { + var form = aResponse.tabs[aResponse.selected]; + var front = FramerateFront(client, form); + + front.startRecording().then(() => { + window.setTimeout(() => { + front.stopRecording(START_TICK, STOP_TICK).then(rawData => { + onRecordingStopped(front, rawData); + }); + }, TOTAL_TIME); + }); + }); + }); + + function onRecordingStopped(front, rawData) { + ok(rawData, "There should be a recording available."); + + ok(!rawData.find(e => e < START_TICK), + "There should be no tick before 2000ms."); + ok(!rawData.find(e => e > STOP_TICK), + "There should be no tick after 3000ms."); + + for (var tick of rawData) { + info("Testing tick: " + tick); + is(typeof tick, "number", "All values should be numbers."); + } + + client.close().then(() => { + DebuggerServer.destroy(); + SimpleTest.finish() + }); + } +} +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_framerate_04.html b/devtools/server/tests/mochitest/test_framerate_04.html new file mode 100644 index 000000000..af6747291 --- /dev/null +++ b/devtools/server/tests/mochitest/test_framerate_04.html @@ -0,0 +1,72 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1023018 - Tests if the framerate actor keeps recording after navigations. +--> +<head> + <meta charset="utf-8"> + <title>Framerate actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + var {FramerateFront} = require("devtools/shared/fronts/framerate"); + var {TargetFactory} = require("devtools/client/framework/target"); + + var url = document.getElementById("testContent").href; + attachURL(url, onTab); + + function onTab(_, client, form, contentDoc) { + var contentWin = contentDoc.defaultView; + var chromeWin = Services.wm.getMostRecentWindow("navigator:browser"); + var selectedTab = chromeWin.gBrowser.selectedTab; + + var target = TargetFactory.forTab(selectedTab); + var front = FramerateFront(client, form); + + front.startRecording().then(() => { + window.setTimeout(() => { + front.getPendingTicks().then(firstBatch => { + target.once("will-navigate", () => { + window.setTimeout(() => { + front.stopRecording().then(secondBatch => { + onRecordingStopped(client, firstBatch, secondBatch); + }); + }, 1000); + }); + contentWin.location.reload(); + }); + }, 1000); + }); + } + + function onRecordingStopped(client, firstBatch, secondBatch) { + ok(firstBatch, "There should be a first batch recording available."); + ok(secondBatch, "There should be a second batch recording available."); + + var diff = secondBatch.length - firstBatch.length; + info("Difference in ticks: " + diff); + ok(diff > 0, "More ticks should be recorded in the second batch."); + + ok(firstBatch.every((e) => secondBatch.indexOf(e) != -1), + "All the ticks in the first batch should be in the second batch as well."); + ok(secondBatch.every((e, i, array) => i < array.length - 1 ? e < array[i + 1] : true), + "All the ticks in the final batch should be ascending in value."); + + client.close().then(() => { + DebuggerServer.destroy(); + SimpleTest.finish() + }); + } +} +</script> +</pre> +<a id="testContent" target="_blank" href="inspector_getImageData.html">Test Document</a> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_framerate_05.html b/devtools/server/tests/mochitest/test_framerate_05.html new file mode 100644 index 000000000..96f56a18f --- /dev/null +++ b/devtools/server/tests/mochitest/test_framerate_05.html @@ -0,0 +1,77 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1034648 - Tests whether a framerate recording can be cancelled. +--> +<head> + <meta charset="utf-8"> + <title>Framerate actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +window.onload = function() { + var Cu = Components.utils; + var Cc = Components.classes; + var Ci = Components.interfaces; + + var {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); + var {DebuggerClient} = require("devtools/shared/client/main"); + var {DebuggerServer} = require("devtools/server/main"); + var Services = require("Services"); + + // Always log packets when running tests. + Services.prefs.setBoolPref("devtools.debugger.log", true); + SimpleTest.registerCleanupFunction(function() { + Services.prefs.clearUserPref("devtools.debugger.log"); + }); + + SimpleTest.waitForExplicitFinish(); + + var {FramerateFront} = require("devtools/shared/fronts/framerate"); + + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + + var client = new DebuggerClient(DebuggerServer.connectPipe()); + client.connect().then(function onConnect() { + client.listTabs(function onListTabs(aResponse) { + var form = aResponse.tabs[aResponse.selected]; + var front = FramerateFront(client, form); + + front.startRecording().then(() => { + window.setTimeout(() => { + front.cancelRecording().then(() => { + window.setTimeout(() => { + front.getPendingTicks().then(rawTicks => { + ok(rawTicks, + "The returned pending ticks should be empty (1)."); + is(rawTicks.length, 0, + "The returned pending ticks should be empty (2)."); + + front.stopRecording().then(rawData => { + ok(rawData, + "The returned raw data should be an empty array (1)."); + is(rawData.length, 0, + "The returned raw data should be an empty array (2)."); + + client.close().then(() => { + DebuggerServer.destroy(); + SimpleTest.finish() + }); + }); + }); + }, 1000); + }); + }, 1000); + }); + }); + }); +} +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_framerate_06.html b/devtools/server/tests/mochitest/test_framerate_06.html new file mode 100644 index 000000000..ecb0a71e0 --- /dev/null +++ b/devtools/server/tests/mochitest/test_framerate_06.html @@ -0,0 +1,82 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1171489 - Tests if the framerate actor does not record timestamps from multiple frames. +--> +<head> + <meta charset="utf-8"> + <title>Framerate actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + var {FramerateFront} = require("devtools/shared/fronts/framerate"); + var {TargetFactory} = require("devtools/client/framework/target"); + + var url = document.getElementById("testContent").href; + attachURL(url, onTab); + + function onTab(_, client, form, contentDoc) { + var contentWin = contentDoc.defaultView; + var chromeWin = Services.wm.getMostRecentWindow("navigator:browser"); + var selectedTab = chromeWin.gBrowser.selectedTab; + + var target = TargetFactory.forTab(selectedTab); + var front = FramerateFront(client, form); + + front.startRecording().then(() => { + window.setTimeout(() => { + // Wait for the iframe to be loaded again + window.addEventListener("message", function loaded (event) { + if (event.data === "ready") { + window.removeEventListener("message", loaded); + window.setTimeout(() => { + front.stopRecording().then(ticks => { + onRecordingStopped(client, ticks); + }); + }, 1000); + } + }); + contentWin.location.reload(); + }, 1000); + }); + } + + function onRecordingStopped(client, ticks) { + var diffs = []; + + info(`Got ${ticks.length} ticks.`); + + for (var i = 1; i < ticks.length; i++) { + var prev = ticks[i - 1]; + var curr = ticks[i]; + diffs.push(curr - prev); + info(curr + " - " + (curr - prev)); + } + + // 1000 / 60 => 16.666... so we shouldn't get more than diffs of 16.66.. but + // when we get ticks from other frames they're usually at diffs of < 1. Sometimes + // ticks can still be less than 16ms even on one frame (usually following a very slow + // frame), so use a low number (2) to be our threshold + var THRESHOLD = 2; + ok(ticks.length >= 20, "we should have atleast 20 ticks over the course of two seconds."); + var belowThreshold = diffs.filter(v => v <= THRESHOLD); + ok(belowThreshold.length <= 10, "we should have very few frames less than the threshold"); + + client.close().then(() => { + DebuggerServer.destroy(); + SimpleTest.finish() + }); + } +} +</script> +</pre> +<a id="testContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_getProcess.html b/devtools/server/tests/mochitest/test_getProcess.html new file mode 100644 index 000000000..3c8ca5727 --- /dev/null +++ b/devtools/server/tests/mochitest/test_getProcess.html @@ -0,0 +1,120 @@ +<SDOCTYPv HTM.> +<html> +<!-- +Bug 1060093 - Test DebuggerServer.getProcess +--> +<head> + <meta charset="utf-8"> + <title>Mozilla Bug</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script type="application/javascript;version=1.8"> + +let Cu = Components.utils; +let Cc = Components.classes; +let Ci = Components.interfaces; + +let {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); +let {DebuggerClient} = require("devtools/shared/client/main"); +let {DebuggerServer} = require("devtools/server/main"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + SpecialPowers.pushPrefEnv({ + "set": [ + // Always log packets when running tests. + ["devtools.debugger.log", true], + // Enabled mozbrowser frame to support remote=true + ["dom.mozBrowserFramesEnabled", true], + // Allows creating a branch new process when creation the iframe + ["dom.ipc.processCount", 10], + ] + }, runTests); +} + +function runTests() { + // Instantiate a minimal server + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + } + DebuggerServer.allowChromeProcess = true; + if (!DebuggerServer.createRootActor) { + DebuggerServer.addBrowserActors(); + } + + let client, iframe, processCount; + + function connect() { + // Fake a first connection to the content process + let transport = DebuggerServer.connectPipe(); + client = new DebuggerClient(transport); + client.connect().then(listProcess); + } + + function listProcess() { + // Call listProcesses in order to start receiving new process notifications + client.addListener("processListChanged", function listener() { + client.removeListener("processListChanged", listener); + ok(true, "Received processListChanged event"); + getProcess(); + }); + client.mainRoot.listProcesses(response => { + processCount = response.processes.length; + // Create a remote iframe to spawn a new process + createRemoteIframe(); + }); + } + + function createRemoteIframe() { + iframe = document.createElement("iframe"); + iframe.mozbrowser = true; + iframe.setAttribute("remote", "true"); + iframe.setAttribute("src", "data:text/html,foo"); + document.body.appendChild(iframe); + } + + function getProcess() { + client.mainRoot.listProcesses(response => { + ok(response.processes.length >= 2, "Got at least the parent process and one child"); + is(response.processes.length, processCount+1 , "Got one additional process on the second call to listProcesses"); + + // Connect to the first content processe available + let content = response.processes.filter(p => (!p.parent))[0]; + + client.getProcess(content.id).then(response => { + let actor = response.form; + ok(actor.consoleActor, "Got the console actor"); + ok(actor.chromeDebugger, "Got the thread actor"); + + // Ensure sending at least one request to an actor... + client.request({ + to: actor.consoleActor, + type: "evaluateJS", + text: "var a = 42; a" + }, function (response) { + ok(response.result, 42, "console.eval worked"); + cleanup(); + }); + }); + }); + } + + function cleanup() { + client.close().then(function () { + DebuggerServer.destroy(); + iframe.remove(); + SimpleTest.finish() + }); + } + + connect(); +} + +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-anonymous.html b/devtools/server/tests/mochitest/test_inspector-anonymous.html new file mode 100644 index 000000000..56a911c89 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-anonymous.html @@ -0,0 +1,201 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=777674 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 777674</title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +window.onload = function() { + const {InspectorFront} = + require("devtools/shared/fronts/inspector"); + const {_documentWalker} = + require("devtools/server/actors/inspector"); + const nodeFilterConstants = + require("devtools/shared/dom-node-filter-constants"); + const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + + SpecialPowers.pushPrefEnv({"set": [ + ["dom.webcomponents.enabled", true] + ]}); + SimpleTest.waitForExplicitFinish(); + + let gWalker = null; + let gClient = null; + + addTest(function setup() { + info ("Setting up inspector and walker actors."); + + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + }).then(runNextTest)); + }); + }); + + addAsyncTest(function* testXBLAnonymousInHTMLDocument() { + info ("Testing XBL anonymous in an HTML document."); + let rawToolbarbutton = gInspectee.createElementNS(XUL_NS, "toolbarbutton"); + gInspectee.documentElement.appendChild(rawToolbarbutton); + + let toolbarbutton = yield gWalker.querySelector(gWalker.rootNode, "toolbarbutton"); + let children = yield gWalker.children(toolbarbutton); + + is (toolbarbutton.numChildren, 0, "XBL content is not visible in HTML doc"); + is (children.nodes.length, 0, "XBL content is not returned in HTML doc"); + + runNextTest(); + }); + + addAsyncTest(function* testNativeAnonymous() { + info ("Testing native anonymous content with walker."); + + let select = yield gWalker.querySelector(gWalker.rootNode, "select"); + let children = yield gWalker.children(select); + + is (select.numChildren, 2, "No native anon content for form control"); + is (children.nodes.length, 2, "No native anon content for form control"); + + runNextTest(); + }); + + addAsyncTest(function* testNativeAnonymousStartingNode() { + info ("Tests attaching an element that a walker can't see."); + + let serverWalker = DebuggerServer._searchAllConnectionsForActor(gWalker.actorID); + let docwalker = new _documentWalker( + gInspectee.querySelector("select"), + gInspectee.defaultView, + nodeFilterConstants.SHOW_ALL, + () => { + return nodeFilterConstants.FILTER_ACCEPT + } + ); + let scrollbar = docwalker.lastChild(); + is (scrollbar.tagName, "scrollbar", "An anonymous child has been fetched"); + + let node = yield serverWalker.attachElement(scrollbar); + + ok (node, "A response has arrived"); + ok (node.node, "A node is in the response"); + is (node.node.rawNode.tagName, "SELECT", + "The node has changed to a parent that the walker recognizes"); + + runNextTest(); + }); + + addAsyncTest(function* testPseudoElements() { + info ("Testing pseudo elements with walker."); + + // Markup looks like: <div><::before /><span /><::after /></div> + let pseudo = yield gWalker.querySelector(gWalker.rootNode, "#pseudo"); + let children = yield gWalker.children(pseudo); + + is (pseudo.numChildren, 1, "::before/::after are not counted if there is a child"); + is (children.nodes.length, 3, "Correct number of children"); + + let before = children.nodes[0]; + ok (before.isAnonymous, "Child is anonymous"); + ok (!before._form.isXBLAnonymous, "Child is not XBL anonymous"); + ok (!before._form.isShadowAnonymous, "Child is not shadow anonymous"); + ok (before._form.isNativeAnonymous, "Child is native anonymous"); + + let span = children.nodes[1]; + ok (!span.isAnonymous, "Child is not anonymous"); + + let after = children.nodes[2]; + ok (after.isAnonymous, "Child is anonymous"); + ok (!after._form.isXBLAnonymous, "Child is not XBL anonymous"); + ok (!after._form.isShadowAnonymous, "Child is not shadow anonymous"); + ok (after._form.isNativeAnonymous, "Child is native anonymous"); + + runNextTest(); + }); + + addAsyncTest(function* testEmptyWithPseudo() { + info ("Testing elements with no childrent, except for pseudos."); + + info ("Checking an element whose only child is a pseudo element"); + let pseudo = yield gWalker.querySelector(gWalker.rootNode, "#pseudo-empty"); + let children = yield gWalker.children(pseudo); + + is (pseudo.numChildren, 1, "::before/::after are is counted if there are no other children"); + is (children.nodes.length, 1, "Correct number of children"); + + let before = children.nodes[0]; + ok (before.isAnonymous, "Child is anonymous"); + ok (!before._form.isXBLAnonymous, "Child is not XBL anonymous"); + ok (!before._form.isShadowAnonymous, "Child is not shadow anonymous"); + ok (before._form.isNativeAnonymous, "Child is native anonymous"); + + runNextTest(); + }); + + addAsyncTest(function* testShadowAnonymous() { + info ("Testing shadow DOM content."); + + let shadow = yield gWalker.querySelector(gWalker.rootNode, "#shadow"); + let children = yield gWalker.children(shadow); + + is (shadow.numChildren, 3, "Children of the shadow root are counted"); + is (children.nodes.length, 3, "Children returned from walker"); + + let before = children.nodes[0]; + ok (before.isAnonymous, "Child is anonymous"); + ok (!before._form.isXBLAnonymous, "Child is not XBL anonymous"); + ok (!before._form.isShadowAnonymous, "Child is not shadow anonymous"); + ok (before._form.isNativeAnonymous, "Child is native anonymous"); + + // <h3>Shadow <em>DOM</em></h3> + let shadowChild1 = children.nodes[1]; + ok (shadowChild1.isAnonymous, "Child is anonymous"); + ok (!shadowChild1._form.isXBLAnonymous, "Child is not XBL anonymous"); + ok (shadowChild1._form.isShadowAnonymous, "Child is shadow anonymous"); + ok (!shadowChild1._form.isNativeAnonymous, "Child is not native anonymous"); + + let shadowSubChildren = yield gWalker.children(children.nodes[1]); + is (shadowChild1.numChildren, 2, "Subchildren of the shadow root are counted"); + is (shadowSubChildren.nodes.length, 2, "Subchildren are returned from walker"); + + // <em>DOM</em> + let shadowSubChild = children.nodes[1]; + ok (shadowSubChild.isAnonymous, "Child is anonymous"); + ok (!shadowSubChild._form.isXBLAnonymous, "Child is not XBL anonymous"); + ok (shadowSubChild._form.isShadowAnonymous, "Child is shadow anonymous"); + ok (!shadowSubChild._form.isNativeAnonymous, "Child is not native anonymous"); + + // <select multiple></select> + let shadowChild2 = children.nodes[2]; + ok (shadowChild2.isAnonymous, "Child is anonymous"); + ok (!shadowChild2._form.isXBLAnonymous, "Child is not XBL anonymous"); + ok (shadowChild2._form.isShadowAnonymous, "Child is shadow anonymous"); + ok (!shadowChild2._form.isNativeAnonymous, "Child is not native anonymous"); + + runNextTest(); + }); + + runNextTest(); +}; + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-changeattrs.html b/devtools/server/tests/mochitest/test_inspector-changeattrs.html new file mode 100644 index 000000000..23b7660d2 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-changeattrs.html @@ -0,0 +1,99 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gInspectee = null; +var gClient = null; +var gWalker = null; +var checkActorIDs = []; + +function assertOwnership() { + assertOwnershipTrees(gWalker); +} + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + }).then(runNextTest)); + }); +}); + +addTest(function testChangeAttrs() { + let attrNode = gInspectee.querySelector("#a"); + let attrFront; + promiseDone(gWalker.querySelector(gWalker.rootNode, "#a").then(front => { + attrFront = front; + dump("attrFront is: " + attrFront + "\n"); + // Add a few attributes. + let list = attrFront.startModifyingAttributes(); + list.setAttribute("data-newattr", "newvalue"); + list.setAttribute("data-newattr2", "newvalue"); + return list.apply(); + }).then(() => { + // We're only going to test that the change hit the document. + // There are other tests that make sure changes are propagated + // to the client. + is(attrNode.getAttribute("data-newattr"), "newvalue", "Node should have the first new attribute"); + is(attrNode.getAttribute("data-newattr2"), "newvalue", "Node should have the second new attribute."); + }).then(() => { + // Change an attribute. + let list = attrFront.startModifyingAttributes(); + list.setAttribute("data-newattr", "changedvalue"); + return list.apply(); + }).then(() => { + is(attrNode.getAttribute("data-newattr"), "changedvalue", "Node should have the changed first value."); + is(attrNode.getAttribute("data-newattr2"), "newvalue", "Second value should remain unchanged."); + }).then(() => { + let list = attrFront.startModifyingAttributes(); + list.removeAttribute("data-newattr2"); + return list.apply(); + }).then(() => { + is(attrNode.getAttribute("data-newattr"), "changedvalue", "Node should have the changed first value."); + ok(!attrNode.hasAttribute("data-newattr2"), "Second value should be removed."); + }).then(runNextTest)); +}); + +addTest(function cleanup() { + delete gWalker; + delete gInspectee; + delete gClient; + runNextTest(); +}); + + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-changevalue.html b/devtools/server/tests/mochitest/test_inspector-changevalue.html new file mode 100644 index 000000000..a5b613157 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-changevalue.html @@ -0,0 +1,82 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const Ci = Components.interfaces; +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gInspectee = null; +var gClient = null; +var gWalker = null; + +function assertOwnership() { + assertOwnershipTrees(gWalker); +} + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + }).then(runNextTest)); + }); +}); + +addTest(function testChangeValue() { + let contentNode = gInspectee.querySelector("#a").firstChild; + let nodeFront; + promiseDone(gWalker.querySelector(gWalker.rootNode, "#a").then(front => { + // Get the text child + return gWalker.children(front, { maxNodes: 1 }); + }).then(children => { + nodeFront = children.nodes[0]; + is(nodeFront.nodeType, Ci.nsIDOMNode.TEXT_NODE); + return nodeFront.setNodeValue("newvalue"); + }).then(() => { + // We're only going to test that the change hit the document. + // There are other tests that make sure changes are propagated + // to the client. + is(contentNode.nodeValue, "newvalue", "Node should have a new value."); + }).then(runNextTest)); +}); + +addTest(function cleanup() { + delete gWalker; + delete gInspectee; + delete gClient; + runNextTest(); +}); + + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-dead-nodes.html b/devtools/server/tests/mochitest/test_inspector-dead-nodes.html new file mode 100644 index 000000000..274636cd6 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-dead-nodes.html @@ -0,0 +1,386 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=1121528 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 1121528</title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gWalker, gDoc; + +addAsyncTest(function() { + let url = document.getElementById("inspectorContent").href; + + let def = promise.defer(); + attachURL(url, function(err, client, tab, doc) { + def.resolve({client, tab, doc}); + }); + let {client, tab, doc} = yield def.promise; + gDoc = doc; + + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + gWalker = yield inspector.getWalker(); + + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.parents(nodeFront) before the load completes shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.parents(nodeFront); + yield newRoot; + + ok(true, "The call to walker.parents() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.children(nodeFront) before the load completes shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "body"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.children(nodeFront); + yield newRoot; + + ok(true, "The call to walker.children() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.siblings(nodeFront) before the load completes shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.siblings(nodeFront); + yield newRoot; + + ok(true, "The call to walker.siblings() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.nextSibling(nodeFront) before the load completes shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.nextSibling(nodeFront); + yield newRoot; + + ok(true, "The call to walker.nextSibling() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.previousSibling(nodeFront) before the load completes shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.previousSibling(nodeFront); + yield newRoot; + + ok(true, "The call to walker.previousSibling() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.addPseudoClassLock(nodeFront) before the load completes " + + "shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.addPseudoClassLock(nodeFront, ":hover"); + yield newRoot; + + ok(true, "The call to walker.addPseudoClassLock() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.removePseudoClassLock(nodeFront) before the load completes " + + "shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.removePseudoClassLock(nodeFront, ":hover"); + yield newRoot; + + ok(true, "The call to walker.removePseudoClassLock() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.clearPseudoClassLocks(nodeFront) before the load completes " + + "shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.clearPseudoClassLocks(nodeFront); + yield newRoot; + + ok(true, "The call to walker.clearPseudoClassLocks() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.innerHTML(nodeFront) before the load completes shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.innerHTML(nodeFront); + yield newRoot; + + ok(true, "The call to walker.innerHTML() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.setInnerHTML(nodeFront) before the load completes shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.setInnerHTML(nodeFront, "<span>innerHTML changed</span>"); + yield newRoot; + + ok(true, "The call to walker.setInnerHTML() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.outerHTML(nodeFront) before the load completes shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.outerHTML(nodeFront); + yield newRoot; + + ok(true, "The call to walker.outerHTML() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.setOuterHTML(nodeFront) before the load completes shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.setOuterHTML(nodeFront, "<h1><span>innerHTML changed</span></h1>"); + yield newRoot; + + ok(true, "The call to walker.setOuterHTML() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.insertAdjacentHTML(nodeFront) before the load completes shouldn't " + + "fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.insertAdjacentHTML(nodeFront, "afterEnd", + "<span>new adjacent HTML</span>"); + yield newRoot; + + ok(true, "The call to walker.insertAdjacentHTML() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.removeNode(nodeFront) before the load completes should throw"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + let hasThrown = false; + try { + yield gWalker.removeNode(nodeFront); + } catch (e) { + hasThrown = true; + } + yield newRoot; + + ok(hasThrown, "The call to walker.removeNode() threw"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.removeNodes([nodeFront]) before the load completes should throw"); + + let nodeFront1 = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let nodeFront2 = yield gWalker.querySelector(gWalker.rootNode, "#longstring"); + let nodeFront3 = yield gWalker.querySelector(gWalker.rootNode, "#shortstring"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + let hasThrown = false; + try { + yield gWalker.removeNodes([nodeFront1, nodeFront2, nodeFront3]); + } catch (e) { + hasThrown = true; + } + yield newRoot; + + ok(hasThrown, "The call to walker.removeNodes() threw"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.insertBefore(nodeFront, parent, null) before the load completes " + + "shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newParentFront = yield gWalker.querySelector(gWalker.rootNode, "#longlist"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.insertBefore(nodeFront, newParentFront); + yield newRoot; + + ok(true, "The call to walker.insertBefore() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.insertBefore(nodeFront, parent, sibling) before the load completes " + + "shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newParentFront = yield gWalker.querySelector(gWalker.rootNode, "#longlist"); + let siblingFront = yield gWalker.querySelector(gWalker.rootNode, "#b"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.insertBefore(nodeFront, newParentFront, siblingFront); + yield newRoot; + + ok(true, "The call to walker.insertBefore() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.editTagName(nodeFront) before the load completes shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.editTagName(nodeFront, "h2"); + yield newRoot; + + ok(true, "The call to walker.editTagName() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.hideNode(nodeFront) before the load completes shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.hideNode(nodeFront); + yield newRoot; + + ok(true, "The call to walker.hideNode() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.unhideNode(nodeFront) before the load completes shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.unhideNode(nodeFront); + yield newRoot; + + ok(true, "The call to walker.unhideNode() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.releaseNode(nodeFront) before the load completes shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "h1"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.releaseNode(nodeFront); + yield newRoot; + + ok(true, "The call to walker.releaseNode() didn't fail"); + runNextTest(); +}); + +addAsyncTest(function() { + info("Getting a nodeFront, reloading the page, and calling " + + "walker.querySelector(nodeFront) before the load completes shouldn't fail"); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, "body"); + let newRoot = waitForMutation(gWalker, isNewRoot); + gDoc.defaultView.location.reload(); + yield gWalker.querySelector(nodeFront, "h1"); + yield newRoot; + + ok(true, "The call to walker.querySelector() didn't fail"); + runNextTest(); +}); + +addTest(function cleanup() { + gWalker = gDoc = null; + runNextTest(); +}); + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=932937">Mozilla Bug 1121528</a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-duplicate-node.html b/devtools/server/tests/mochitest/test_inspector-duplicate-node.html new file mode 100644 index 000000000..35722f226 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-duplicate-node.html @@ -0,0 +1,75 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=1208864 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 1208864</title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gInspectee = null; +var gClient = null; +var gWalker = null; + +function assertOwnership() { + assertOwnershipTrees(gWalker); +} + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + }).then(runNextTest)); + }); +}); + +addTest(Task.async(function* testDuplicateNode() { + let className = ".node-to-duplicate"; + let matches = yield gWalker.querySelectorAll(gWalker.rootNode, className); + is(matches.length, 1, "There should initially be one node to duplicate."); + + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, className); + yield gWalker.duplicateNode(nodeFront); + + matches = yield gWalker.querySelectorAll(gWalker.rootNode, className); + is(matches.length, 2, "The node should now be duplicated."); + + runNextTest(); +})); + +addTest(function cleanup() { + delete gWalker; + delete gInspectee; + delete gClient; + runNextTest(); +}); + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1208864">Mozilla Bug 1208864</a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-hide.html b/devtools/server/tests/mochitest/test_inspector-hide.html new file mode 100644 index 000000000..d9b134c22 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-hide.html @@ -0,0 +1,76 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gWalker = null; +var gClient = null; + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + }).then(runNextTest)); + }); +}); + +addTest(function testRearrange() { + let listFront = null; + let listNode = gInspectee.querySelector("#longlist"); + + promiseDone(gWalker.querySelector(gWalker.rootNode, "#longlist").then(front => { + listFront = front; + }).then(() => { + let computed = gInspectee.defaultView.getComputedStyle(listNode); + ok(computed.visibility, "visible", "Node should be visible to start with"); + return gWalker.hideNode(listFront); + }).then(response => { + let computed = gInspectee.defaultView.getComputedStyle(listNode); + ok(computed.visibility, "hidden", "Node should be hidden"); + return gWalker.unhideNode(listFront); + }).then(() => { + let computed = gInspectee.defaultView.getComputedStyle(listNode); + ok(computed.visibility, "visible", "Node should be visible again."); + }).then(runNextTest)); +}); + +addTest(function cleanup() { + delete gWalker; + delete gClient; + runNextTest(); +}); + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-insert.html b/devtools/server/tests/mochitest/test_inspector-insert.html new file mode 100644 index 000000000..82b4fef3e --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-insert.html @@ -0,0 +1,119 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/server/actors/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gWalker = null; +var gClient = null; + +function assertOwnership() { + return assertOwnershipTrees(gWalker); +} + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + }).then(runNextTest)); + }); +}); + +addAsyncTest(function* testRearrange() { + let longlist = yield gWalker.querySelector(gWalker.rootNode, "#longlist"); + let children = yield gWalker.children(longlist); + let nodeA = children.nodes[0]; + is(nodeA.id, "a", "Got the expected node."); + + // Move nodeA to the end of the list. + yield gWalker.insertBefore(nodeA, longlist, null); + ok(!gInspectee.querySelector("#a").nextSibling, "a should now be at the end of the list."); + children = yield gWalker.children(longlist); + is(nodeA, children.nodes[children.nodes.length - 1], "a should now be the last returned child."); + + // Now move it to the middle of the list. + let nextNode = children.nodes[13]; + yield gWalker.insertBefore(nodeA, longlist, nextNode); + let sibling = + new inspector._documentWalker(gInspectee.querySelector("#a"), window).nextSibling(); + is(sibling, nextNode.rawNode(), "Node should match the expected next node."); + children = yield gWalker.children(longlist); + is(nodeA, children.nodes[13], "a should be where we expect it."); + is(nextNode, children.nodes[14], "next node should be where we expect it."); + + runNextTest(); +}); + +addAsyncTest(function* testInsertInvalidInput() { + let longlist = yield gWalker.querySelector(gWalker.rootNode, "#longlist"); + let children = yield gWalker.children(longlist); + let nodeA = children.nodes[0]; + let nextSibling = children.nodes[1]; + + // Now move it to the original location and make sure no mutation happens. + let hasMutated = false; + let observer = new gInspectee.defaultView.MutationObserver(() => { + hasMutated = true; + }); + observer.observe(longlist.rawNode(), { + childList: true, + }); + + yield gWalker.insertBefore(nodeA, longlist, nodeA); + ok(!hasMutated, "hasn't mutated"); + hasMutated = false; + + yield gWalker.insertBefore(nodeA, longlist, nextSibling); + ok(!hasMutated, "still hasn't mutated after inserting before nextSibling"); + hasMutated = false; + + yield gWalker.insertBefore(nodeA, longlist); + ok(hasMutated, "has mutated after inserting with null sibling"); + hasMutated = false; + + yield gWalker.insertBefore(nodeA, longlist); + ok(!hasMutated, "hasn't mutated after inserting with null sibling again"); + + observer.disconnect(); + runNextTest(); +}); + +addTest(function cleanup() { + delete gWalker; + delete gClient; + runNextTest(); +}); + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-mutations-attr.html b/devtools/server/tests/mochitest/test_inspector-mutations-attr.html new file mode 100644 index 000000000..15c14608b --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-mutations-attr.html @@ -0,0 +1,167 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gInspectee = null; +var gWalker = null; +var gClient = null; +var attrNode; +var attrFront; + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + }).then(runNextTest)); + }); +}); + +addTest(setupAttrTest); +addTest(testAddAttribute); +addTest(testChangeAttribute); +addTest(testRemoveAttribute); +addTest(testQueuedMutations); +addTest(setupFrameAttrTest); +addTest(testAddAttribute); +addTest(testChangeAttribute); +addTest(testRemoveAttribute); +addTest(testQueuedMutations); + +function setupAttrTest() { + attrNode = gInspectee.querySelector("#a") + promiseDone(gWalker.querySelector(gWalker.rootNode, "#a").then(node => { + attrFront = node; + }).then(runNextTest)); +} + +function setupFrameAttrTest() { + let frame = gInspectee.querySelector('#childFrame'); + attrNode = frame.contentDocument.querySelector("#a"); + + promiseDone(gWalker.querySelector(gWalker.rootNode, "#childFrame").then(childFrame => { + return gWalker.children(childFrame); + }).then(children => { + let nodes = children.nodes; + ok(nodes.length, 1, "There should be only one child of the iframe"); + is(nodes[0].nodeType, Node.DOCUMENT_NODE, "iframe child should be a document node"); + return gWalker.querySelector(nodes[0], "#a"); + }).then(node => { + attrFront = node; + }).then(runNextTest)); +} + +function testAddAttribute() { + attrNode.setAttribute("data-newattr", "newvalue"); + attrNode.setAttribute("data-newattr2", "newvalue"); + gWalker.once("mutations", () => { + is(attrFront.attributes.length, 3, "Should have id and two new attributes."); + is(attrFront.getAttribute("data-newattr"), "newvalue", "Node front should have the first new attribute"); + is(attrFront.getAttribute("data-newattr2"), "newvalue", "Node front should have the second new attribute."); + runNextTest(); + }); +} + +function testChangeAttribute() { + attrNode.setAttribute("data-newattr", "changedvalue1"); + attrNode.setAttribute("data-newattr", "changedvalue2"); + attrNode.setAttribute("data-newattr", "changedvalue3"); + gWalker.once("mutations", mutations => { + is(mutations.length, 1, "Only one mutation is sent for multiple queued attribute changes"); + is(attrFront.attributes.length, 3, "Should have id and two new attributes."); + is(attrFront.getAttribute("data-newattr"), "changedvalue3", "Node front should have the changed first value"); + is(attrFront.getAttribute("data-newattr2"), "newvalue", "Second value should remain unchanged."); + runNextTest(); + }); +} + +function testRemoveAttribute() { + attrNode.removeAttribute("data-newattr2"); + gWalker.once("mutations", () => { + is(attrFront.attributes.length, 2, "Should have id and one remaining attribute."); + is(attrFront.getAttribute("data-newattr"), "changedvalue3", "Node front should still have the first value"); + ok(!attrFront.hasAttribute("data-newattr2"), "Second value should be removed."); + runNextTest(); + }) +} + +function testQueuedMutations() { + // All modifications to each attribute should be queued in one mutation event. + + attrNode.removeAttribute("data-newattr"); + attrNode.setAttribute("data-newattr", "1"); + attrNode.removeAttribute("data-newattr"); + attrNode.setAttribute("data-newattr", "2"); + attrNode.removeAttribute("data-newattr"); + + for (var i = 0; i <= 1000; i++) { + attrNode.setAttribute("data-newattr2", i); + } + + attrNode.removeAttribute("data-newattr3"); + attrNode.setAttribute("data-newattr3", "1"); + attrNode.removeAttribute("data-newattr3"); + attrNode.setAttribute("data-newattr3", "2"); + attrNode.removeAttribute("data-newattr3"); + attrNode.setAttribute("data-newattr3", "3"); + + // This shouldn't be added in the attribute set, since it's a new + // attribute that's been added and removed. + attrNode.setAttribute("data-newattr4", "4"); + attrNode.removeAttribute("data-newattr4"); + + gWalker.once("mutations", mutations => { + is(mutations.length, 4, "Only one mutation each is sent for multiple queued attribute changes"); + is(attrFront.attributes.length, 3, "Should have id, data-newattr2, and data-newattr3."); + + is(attrFront.getAttribute("data-newattr2"), "1000", "Node front should still have the correct value"); + is(attrFront.getAttribute("data-newattr3"), "3", "Node front should still have the correct value"); + ok(!attrFront.hasAttribute("data-newattr"), "Attribute value should be removed."); + ok(!attrFront.hasAttribute("data-newattr4"), "Attribute value should be removed."); + + runNextTest(); + }) +} + +addTest(function cleanup() { + delete gInspectee; + delete gWalker; + delete gClient; + runNextTest(); +}); + + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-mutations-childlist.html b/devtools/server/tests/mochitest/test_inspector-mutations-childlist.html new file mode 100644 index 000000000..d845b987e --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-mutations-childlist.html @@ -0,0 +1,310 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gInspectee = null; +var gWalker = null; +var gClient = null; +var gCleanupConnection = null; + +function setup(callback) { + let url = document.getElementById("inspectorContent").href; + gCleanupConnection = attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + gClient = client; + gWalker = walker; + }).then(callback)); + }); +} + +function teardown() { + gWalker = null; + gClient = null; + gInspectee = null; + if (gCleanupConnection) { + gCleanupConnection(); + gCleanupConnection = null; + } +} + +function assertOwnership() { + let num = assertOwnershipTrees(gWalker); +} + +function setParent(nodeSelector, newParentSelector) { + let node = gInspectee.querySelector(nodeSelector); + if (newParentSelector) { + let newParent = gInspectee.querySelector(newParentSelector); + newParent.appendChild(node); + } else { + node.parentNode.removeChild(node); + } +} + +function loadSelector(selector) { + return gWalker.querySelectorAll(gWalker.rootNode, selector).then(nodeList => { + return nodeList.items(); + }); +} + +function loadSelectors(selectors) { + return promise.all(Array.from(selectors, (sel) => loadSelector(sel))); +} + +function doMoves(moves) { + for (let move of moves) { + setParent(move[0], move[1]); + } +} + +/** + * Test a set of tree rearrangements and make sure they cause the expected changes. + */ + +var gDummySerial = 0; + +function mutationTest(testSpec) { + return function() { + setup(() => { + promiseDone(loadSelectors(testSpec.load || ["html"]).then(() => { + gWalker.autoCleanup = !!testSpec.autoCleanup; + if (testSpec.preCheck) { + testSpec.preCheck(); + } + doMoves(testSpec.moves || []); + + // Some of these moves will trigger no mutation events, + // so do a dummy change to the root node to trigger + // a mutation event anyway. + gInspectee.documentElement.setAttribute("data-dummy", gDummySerial++); + + gWalker.once("mutations", (mutations) => { + // Filter out our dummy mutation. + mutations = mutations.filter(change => { + if (change.type == "attributes" && + change.attributeName == "data-dummy") { + return false; + } + return true; + }); + assertOwnership(); + if (testSpec.postCheck) { + testSpec.postCheck(mutations); + } + teardown(); + runNextTest(); + }); + })); + }) + } +} + +// Verify that our dummy mutation works. +addTest(mutationTest({ + autoCleanup: false, + postCheck: function(mutations) { + is(mutations.length, 0, "Dummy mutation is filtered out."); + } +})); + +// Test a simple move to a different location in the sibling list for the same +// parent. +addTest(mutationTest({ + autoCleanup: false, + load: ["#longlist div"], + moves: [ + ["#a", "#longlist"] + ], + postCheck: function(mutations) { + let remove = mutations[0]; + is(remove.type, "childList", "First mutation should be a childList.") + ok(remove.removed.length > 0, "First mutation should be a removal.") + let add = mutations[1]; + is(add.type, "childList", "Second mutation should be a childList removal.") + ok(add.added.length > 0, "Second mutation should be an addition.") + let a = add.added[0]; + is(a.id, "a", "Added node should be #a"); + is(a.parentNode(), remove.target, "Should still be a child of longlist."); + is(remove.target, add.target, "First and second mutations should be against the same node."); + } +})); + +// Test a move to another location that is within our ownership tree. +addTest(mutationTest({ + autoCleanup: false, + load: ["#longlist div", "#longlist-sibling"], + moves: [ + ["#a", "#longlist-sibling"] + ], + postCheck: function(mutations) { + let remove = mutations[0]; + is(remove.type, "childList", "First mutation should be a childList.") + ok(remove.removed.length > 0, "First mutation should be a removal.") + let add = mutations[1]; + is(add.type, "childList", "Second mutation should be a childList removal.") + ok(add.added.length > 0, "Second mutation should be an addition.") + let a = add.added[0]; + is(a.id, "a", "Added node should be #a"); + is(a.parentNode(), add.target, "Should still be a child of longlist."); + is(add.target.id, "longlist-sibling", "long-sibling should be the target."); + } +})); + +// Move an unseen node with a seen parent into our ownership tree - should generate a +// childList pair with no adds or removes. +addTest(mutationTest({ + autoCleanup: false, + load: ["#longlist"], + moves: [ + ["#longlist-sibling", "#longlist"] + ], + postCheck: function(mutations) { + is(mutations.length, 2, "Should generate two mutations"); + is(mutations[0].type, "childList", "Should be childList mutations."); + is(mutations[0].added.length, 0, "Should have no adds."); + is(mutations[0].removed.length, 0, "Should have no removes."); + is(mutations[1].type, "childList", "Should be childList mutations."); + is(mutations[1].added.length, 0, "Should have no adds."); + is(mutations[1].removed.length, 0, "Should have no removes."); + } +})); + +// Move an unseen node with an unseen parent into our ownership tree. Should only +// generate one childList mutation with no adds or removes. +addTest(mutationTest({ + autoCleanup: false, + load: ["#longlist div"], + moves: [ + ["#longlist-sibling-firstchild", "#longlist"] + ], + postCheck: function(mutations) { + is(mutations.length, 1, "Should generate two mutations"); + is(mutations[0].type, "childList", "Should be childList mutations."); + is(mutations[0].added.length, 0, "Should have no adds."); + is(mutations[0].removed.length, 0, "Should have no removes."); + } +})); + +// Move a node between unseen nodes, should generate no mutations. +addTest(mutationTest({ + autoCleanup: false, + load: ["html"], + moves: [ + ["#longlist-sibling", "#longlist"] + ], + postCheck: function(mutations) { + is(mutations.length, 0, "Should generate no mutations."); + } +})); + +// Orphan a node and don't clean it up +addTest(mutationTest({ + autoCleanup: false, + load: ["#longlist div"], + moves: [ + ["#longlist", null] + ], + postCheck: function(mutations) { + is(mutations.length, 1, "Should generate one mutation."); + let change = mutations[0]; + is(change.type, "childList", "Should be a childList."); + is(change.removed.length, 1, "Should have removed a child."); + let ownership = clientOwnershipTree(gWalker); + is(ownership.orphaned.length, 1, "Should have one orphaned subtree."); + is(ownershipTreeSize(ownership.orphaned[0]), 1 + 26 + 26, "Should have orphaned longlist, and 26 children, and 26 singleTextChilds"); + } +})); + +// Orphan a node, and do clean it up. +addTest(mutationTest({ + autoCleanup: true, + load: ["#longlist div"], + moves: [ + ["#longlist", null] + ], + postCheck: function(mutations) { + is(mutations.length, 1, "Should generate one mutation."); + let change = mutations[0]; + is(change.type, "childList", "Should be a childList."); + is(change.removed.length, 1, "Should have removed a child."); + let ownership = clientOwnershipTree(gWalker); + is(ownership.orphaned.length, 0, "Should have no orphaned subtrees."); + } +})); + +// Orphan a node by moving it into the tree but out of our visible subtree. +addTest(mutationTest({ + autoCleanup: false, + load: ["#longlist div"], + moves: [ + ["#longlist", "#longlist-sibling"] + ], + postCheck: function(mutations) { + is(mutations.length, 1, "Should generate one mutation."); + let change = mutations[0]; + is(change.type, "childList", "Should be a childList."); + is(change.removed.length, 1, "Should have removed a child."); + let ownership = clientOwnershipTree(gWalker); + is(ownership.orphaned.length, 1, "Should have one orphaned subtree."); + is(ownershipTreeSize(ownership.orphaned[0]), 1 + 26 + 26, "Should have orphaned longlist, 26 children, and 26 singleTextChilds."); + } +})); + +// Orphan a node by moving it into the tree but out of our visible subtree, and clean it up. +addTest(mutationTest({ + autoCleanup: true, + load: ["#longlist div"], + moves: [ + ["#longlist", "#longlist-sibling"] + ], + postCheck: function(mutations) { + is(mutations.length, 1, "Should generate one mutation."); + let change = mutations[0]; + is(change.type, "childList", "Should be a childList."); + is(change.removed.length, 1, "Should have removed a child."); + let ownership = clientOwnershipTree(gWalker); + is(ownership.orphaned.length, 0, "Should have no orphaned subtrees."); + } +})); + + +addTest(function cleanup() { + delete gInspectee; + delete gWalker; + delete gClient; + runNextTest(); +}); + + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-mutations-events.html b/devtools/server/tests/mochitest/test_inspector-mutations-events.html new file mode 100644 index 000000000..992bc7f8d --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-mutations-events.html @@ -0,0 +1,183 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=1157469 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 1157469</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> + +window.onload = function() { + + const Cu = Components.utils; + const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); + const {InspectorFront} = require("devtools/shared/fronts/inspector"); + + SimpleTest.waitForExplicitFinish(); + + let inspectee = null; + let inspector = null; + let walker = null; + let eventListener1 = function () {}; + let eventListener2 = function () {}; + let eventNode1; + let eventNode2; + let eventFront1; + let eventFront2; + + addAsyncTest(function* setup() { + info ("Setting up inspector and walker actors."); + let url = document.getElementById("inspectorContent").href; + + yield new Promise(resolve => { + attachURL(url, function(err, client, tab, doc) { + inspectee = doc; + inspector = InspectorFront(client, tab); + resolve(); + }); + }); + + walker = yield inspector.getWalker(); + ok(walker, "getWalker() should return an actor."); + + runNextTest(); + }); + + addAsyncTest(function* setupEventTest() { + eventNode1 = inspectee.querySelector("#a") + eventNode2 = inspectee.querySelector("#b") + + eventFront1 = yield walker.querySelector(walker.rootNode, "#a"); + eventFront2 = yield walker.querySelector(walker.rootNode, "#b"); + + runNextTest(); + }); + + addAsyncTest(function* testChangeEventListenerOnSingleNode() { + checkNodesHaveNoEventListener(); + + info("add event listener on a single node"); + eventNode1.addEventListener("click", eventListener1); + + let mutations = yield waitForMutations(); + is(mutations.length, 1, "one mutation expected"); + is(mutations[0].target, eventFront1, "mutation targets eventFront1"); + is(mutations[0].type, "events", "mutation type is events"); + is(mutations[0].hasEventListeners, true, "mutation target should have event listeners"); + is(eventFront1.hasEventListeners, true, "eventFront1 should have event listeners"); + + info("remove event listener on a single node"); + eventNode1.removeEventListener("click", eventListener1); + + mutations = yield waitForMutations(); + is(mutations.length, 1, "one mutation expected"); + is(mutations[0].target, eventFront1, "mutation targets eventFront1"); + is(mutations[0].type, "events", "mutation type is events"); + is(mutations[0].hasEventListeners, false, "mutation target should have no event listeners"); + is(eventFront1.hasEventListeners, false, "eventFront1 should have no event listeners"); + + info("perform several event listener changes on a single node") + eventNode1.addEventListener("click", eventListener1); + eventNode1.addEventListener("click", eventListener2); + eventNode1.removeEventListener("click", eventListener1); + eventNode1.removeEventListener("click", eventListener2); + + mutations = yield waitForMutations(); + is(mutations.length, 1, "one mutation expected"); + is(mutations[0].target, eventFront1, "mutation targets eventFront1"); + is(mutations[0].type, "events", "mutation type is events"); + is(mutations[0].hasEventListeners, false, "no event listener expected on mutation target"); + is(eventFront1.hasEventListeners, false, "no event listener expected on node"); + + runNextTest(); + }); + + addAsyncTest(function* testChangeEventsOnSeveralNodes() { + checkNodesHaveNoEventListener(); + + info("add event listeners on both nodes"); + eventNode1.addEventListener("click", eventListener1); + eventNode2.addEventListener("click", eventListener2); + + let mutations = yield waitForMutations(); + is(mutations.length, 2, "two mutations expected, one for each modified node"); + // first mutation + is(mutations[0].target, eventFront1, "first mutation targets eventFront1"); + is(mutations[0].type, "events", "mutation type is events"); + is(mutations[0].hasEventListeners, true, "mutation target should have event listeners"); + is(eventFront1.hasEventListeners, true, "eventFront1 should have event listeners"); + // second mutation + is(mutations[1].target, eventFront2, "second mutation targets eventFront2"); + is(mutations[1].type, "events", "mutation type is events"); + is(mutations[1].hasEventListeners, true, "mutation target should have event listeners"); + is(eventFront2.hasEventListeners, true, "eventFront1 should have event listeners"); + + info("remove event listeners on both nodes"); + eventNode1.removeEventListener("click", eventListener1); + eventNode2.removeEventListener("click", eventListener2); + + mutations = yield waitForMutations(); + is(mutations.length, 2, "one mutation registered for event listener change"); + // first mutation + is(mutations[0].target, eventFront1, "first mutation targets eventFront1"); + is(mutations[0].type, "events", "mutation type is events"); + is(mutations[0].hasEventListeners, false, "mutation target should have no event listeners"); + is(eventFront1.hasEventListeners, false, "eventFront2 should have no event listeners"); + // second mutation + is(mutations[1].target, eventFront2, "second mutation targets eventFront2"); + is(mutations[1].type, "events", "mutation type is events"); + is(mutations[1].hasEventListeners, false, "mutation target should have no event listeners"); + is(eventFront2.hasEventListeners, false, "eventFront2 should have no event listeners"); + + runNextTest(); + }); + + addAsyncTest(function* testRemoveMissingEvent() { + checkNodesHaveNoEventListener(); + + info("try to remove an event listener not previously added"); + eventNode1.removeEventListener("click", eventListener1); + + info("set any attribute on the node to trigger a mutation") + eventNode1.setAttribute("data-attr", "somevalue"); + + let mutations = yield waitForMutations(); + is(mutations.length, 1, "expect only one mutation"); + isnot(mutations.type, "events", "mutation type should not be events"); + + runNextTest(); + }); + + function checkNodesHaveNoEventListener() { + is(eventFront1.hasEventListeners, false, "eventFront1 hasEventListeners should be false"); + is(eventFront2.hasEventListeners, false, "eventFront2 hasEventListeners should be false"); + }; + + function waitForMutations() { + return new Promise(resolve => { + walker.once("mutations", mutations => { + resolve(mutations); + }); + }); + } + + runNextTest(); +} + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1157469">Mozilla Bug 1157469</a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-mutations-frameload.html b/devtools/server/tests/mochitest/test_inspector-mutations-frameload.html new file mode 100644 index 000000000..54966cea7 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-mutations-frameload.html @@ -0,0 +1,214 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gInspectee = null; +var gWalker = null; +var gClient = null; +var gChildFrame = null; +var gChildDocument = null; +var gCleanupConnection = null; + +function setup(callback) { + let url = document.getElementById("inspectorContent").href; + gCleanupConnection = attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + gClient = client; + gWalker = walker; + }).then(callback)); + }); +} + +function teardown() { + gWalker = null; + gClient = null; + gInspectee = null; + gChildFrame = null; + if (gCleanupConnection) { + gCleanupConnection(); + gCleanupConnection = null; + } +} + +function assertOwnership() { + return assertOwnershipTrees(gWalker); +} + +function loadChildSelector(selector) { + return gWalker.querySelector(gWalker.rootNode, "#childFrame").then(frame => { + ok(frame.numChildren > 0, "Child frame should consider its loaded document as a child."); + gChildFrame = frame; + return gWalker.children(frame); + }).then(children => { + return gWalker.querySelectorAll(children.nodes[0], selector); + }).then(nodeList => { + return nodeList.items(); + }); +} + +function getUnloadedDoc(mutations) { + for (let change of mutations) { + if (isUnload(change)) { + return change.target; + } + } + return null; +} + +addTest(function loadNewChild() { + setup(() => { + let beforeUnloadSize = 0; + // Load a bunch of fronts for actors inside the child frame. + promiseDone(loadChildSelector("#longlist div").then(() => { + let childFrame = gInspectee.querySelector("#childFrame"); + childFrame.src = "data:text/html,<html>new child</html>"; + return waitForMutation(gWalker, isChildList); + }).then(mutations => { + let unloaded = getUnloadedDoc(mutations); + mutations = assertSrcChange(mutations); + mutations = assertUnload(mutations); + mutations = assertFrameLoad(mutations); + mutations = assertChildList(mutations); + + is(mutations.length, 0, "Got the expected mutations."); + + assertOwnership(); + + return checkMissing(gClient, unloaded); + }).then(() => { + teardown(); + }).then(runNextTest)); + }); +}); + +addTest(function loadNewChildTwice() { + setup(() => { + let beforeUnloadSize = 0; + // Load a bunch of fronts for actors inside the child frame. + promiseDone(loadChildSelector("#longlist div").then(() => { + let childFrame = gInspectee.querySelector("#childFrame"); + childFrame.src = "data:text/html,<html>new child</html>"; + return waitForMutation(gWalker, isChildList); + }).then(mutations => { + // The first load went through as expected (as tested in loadNewChild) + // Now change the source again, but this time we *don't* expect + // an unload, because we haven't seen the new child document yet. + let childFrame = gInspectee.querySelector("#childFrame"); + childFrame.src = "data:text/html,<html>second new child</html>"; + return waitForMutation(gWalker, isChildList); + }).then(mutations => { + mutations = assertSrcChange(mutations); + mutations = assertFrameLoad(mutations); + mutations = assertChildList(mutations); + ok(!getUnloadedDoc(mutations), "Should not have gotten an unload."); + + is(mutations.length, 0, "Got the expected mutations."); + + assertOwnership(); + }).then(() => { + teardown(); + }).then(runNextTest)); + }); +}); + + +addTest(function loadNewChildTwiceAndCareAboutIt() { + setup(() => { + let beforeUnloadSize = 0; + // Load a bunch of fronts for actors inside the child frame. + promiseDone(loadChildSelector("#longlist div").then(() => { + let childFrame = gInspectee.querySelector("#childFrame"); + childFrame.src = "data:text/html,<html>new child</html>"; + return waitForMutation(gWalker, isChildList); + }).then(mutations => { + // Read the new child + return loadChildSelector("#longlist div"); + }).then(() => { + // Now change the source again, and expect the same results as loadNewChild. + let childFrame = gInspectee.querySelector("#childFrame"); + childFrame.src = "data:text/html,<html>second new child</html>"; + return waitForMutation(gWalker, isChildList); + }).then(mutations => { + let unloaded = getUnloadedDoc(mutations); + + mutations = assertSrcChange(mutations); + mutations = assertUnload(mutations); + mutations = assertFrameLoad(mutations); + mutations = assertChildList(mutations); + + is(mutations.length, 0, "Got the expected mutations."); + + assertOwnership(); + + return checkMissing(gClient, unloaded); + }).then(() => { + teardown(); + }).then(runNextTest)); + }); +}); + +addTest(function testBack() { + setup(() => { + let beforeUnloadSize = 0; + // Load a bunch of fronts for actors inside the child frame. + promiseDone(loadChildSelector("#longlist div").then(() => { + let childFrame = gInspectee.querySelector("#childFrame"); + childFrame.src = "data:text/html,<html>new child</html>"; + return waitForMutation(gWalker, isChildList); + }).then(mutations => { + // Read the new child + return loadChildSelector("#longlist div"); + }).then(() => { + // Now use history.back to change the source, and expect the same results as loadNewChild. + let childFrame = gInspectee.querySelector("#childFrame"); + childFrame.contentWindow.history.back(); + return waitForMutation(gWalker, isChildList); + }).then(mutations => { + let unloaded = getUnloadedDoc(mutations); + mutations = assertSrcChange(mutations); + mutations = assertUnload(mutations); + mutations = assertFrameLoad(mutations); + mutations = assertChildList(mutations); + is(mutations.length, 0, "Got the expected mutations."); + + assertOwnership(); + + return checkMissing(gClient, unloaded); + }).then(() => { + teardown(); + }).then(runNextTest)); + }); +}); + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-mutations-value.html b/devtools/server/tests/mochitest/test_inspector-mutations-value.html new file mode 100644 index 000000000..352526b13 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-mutations-value.html @@ -0,0 +1,168 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/server/actors/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +const testSummaryLength = 10; +inspector.setValueSummaryLength(testSummaryLength); +SimpleTest.registerCleanupFunction(function() { + inspector.setValueSummaryLength(inspector.DEFAULT_VALUE_SUMMARY_LENGTH); +}); + +var gInspectee = null; +var gWalker = null; +var gClient = null; +var valueNode; +var valueFront; +var longStringFront; +var longString = "stringstringstringstringstringstringstringstringstringstringstring"; +var truncatedLongString = longString.substring(0, testSummaryLength); +var shortString = "str"; +var shortString2 = "str2"; + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + }).then(runNextTest)); + }); +}); + +addTest(setupValueTest); +addTest(testKeepLongValue); +addTest(testSetShortValue); +addTest(testKeepShortValue); +addTest(testSetLongValue); +addTest(setupFrameValueTest); +addTest(testKeepLongValue); +addTest(testSetShortValue); +addTest(testKeepShortValue); +addTest(testSetLongValue); + +function setupValueTest() { + valueNode = gInspectee.querySelector("#longstring").firstChild; + promiseDone(gWalker.querySelector(gWalker.rootNode, "#longstring").then(node => { + longStringFront = node; + return gWalker.children(node); + }).then(children => { + valueFront = children.nodes[0]; + }).then(runNextTest)); +} + +function setupFrameValueTest() { + let frame = gInspectee.querySelector('#childFrame'); + valueNode = frame.contentDocument.querySelector("#longstring").firstChild; + + promiseDone(gWalker.querySelector(gWalker.rootNode, "#childFrame").then(childFrame => { + return gWalker.children(childFrame); + }).then(children => { + let nodes = children.nodes; + is(nodes.length, 1, "There should be only one child of the iframe"); + is(nodes[0].nodeType, Node.DOCUMENT_NODE, "iframe child should be a document node"); + return gWalker.querySelector(nodes[0], "#longstring"); + }).then(node => { + longStringFront = node; + return gWalker.children(node); + }).then(children => { + valueFront = children.nodes[0]; + }).then(runNextTest)); +} + +function checkNodeFrontValue(front, expectedValue) { + return front.getNodeValue().then(longstring => { + return longstring.string(); + }).then(str => { + is(str, expectedValue, "Node value is as expected"); + }) +} + +function testKeepLongValue() { + // After first setup we should have a long string in the node + ok(!longStringFront.inlineTextChild, "Text node is too long to be inlined."); + + valueNode.nodeValue = longString; + gWalker.once("mutations", (changes) => { + ok(!longStringFront.inlineTextChild, "Text node is too long to be inlined."); + ok(!changes.some(change => change.type === "inlineTextChild"), + "No inline text child mutation was fired."); + checkNodeFrontValue(valueFront, longString).then(runNextTest); + }); +} + +function testSetShortValue() { + ok(!longStringFront.inlineTextChild, "Text node is too long to be inlined."); + + valueNode.nodeValue = shortString; + gWalker.once("mutations", (changes) => { + ok(!!longStringFront.inlineTextChild, "Text node is short enough to be inlined."); + ok(changes.some(change => change.type === "inlineTextChild"), + "An inlineTextChild mutation was fired."); + checkNodeFrontValue(valueFront, shortString).then(runNextTest); + }); +} + +function testKeepShortValue() { + ok(!!longStringFront.inlineTextChild, "Text node is short enough to be inlined."); + + valueNode.nodeValue = shortString2; + gWalker.once("mutations", (changes) => { + ok(!!longStringFront.inlineTextChild, "Text node is short enough to be inlined."); + ok(!changes.some(change => change.type === "inlineTextChild"), + "No inline text child mutation was fired."); + checkNodeFrontValue(valueFront, shortString2).then(runNextTest); + }); +} + +function testSetLongValue() { + ok(!!longStringFront.inlineTextChild, "Text node is short enough to be inlined."); + + valueNode.nodeValue = longString; + gWalker.once("mutations", (changes) => { + ok(!longStringFront.inlineTextChild, "Text node is too long to be inlined."); + ok(changes.some(change => change.type === "inlineTextChild"), + "An inlineTextChild mutation was fired."); + checkNodeFrontValue(valueFront, longString).then(runNextTest); + }); +} + +addTest(function cleanup() { + delete gInspectee; + delete gWalker; + delete gClient; + runNextTest(); +}); + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-pick-color.html b/devtools/server/tests/mochitest/test_inspector-pick-color.html new file mode 100644 index 000000000..48ad08468 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-pick-color.html @@ -0,0 +1,101 @@ +<!DOCTYPE HTML>
+<html>
+<!--
+Test that the inspector actor has the pickColorFromPage and cancelPickColorFromPage
+methods and that when a color is picked the color-picked event is emitted and that when
+the eyedropper is dimissed, the color-pick-canceled event is emitted.
+https://bugzilla.mozilla.org/show_bug.cgi?id=1262439
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1262439</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script>
+ <script type="application/javascript;version=1.8">
+window.onload = function() {
+ const Cu = Components.utils;
+ Cu.import("resource://devtools/shared/Loader.jsm");
+ const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
+ const {InspectorFront} = devtools.require("devtools/shared/fronts/inspector");
+ const {console} = Cu.import("resource://gre/modules/Console.jsm", {});
+
+ SimpleTest.waitForExplicitFinish();
+
+ let win = null;
+ let inspector = null;
+
+ addAsyncTest(function*() {
+ info("Setting up inspector actor");
+
+ let url = document.getElementById("inspectorContent").href;
+
+ yield new Promise(resolve => {
+ attachURL(url, function(err, client, tab, doc) {
+ win = doc.defaultView;
+ inspector = InspectorFront(client, tab);
+ resolve();
+ });
+ });
+
+ runNextTest();
+ });
+
+ addAsyncTest(function*() {
+ info("Start picking a color from the page");
+ yield inspector.pickColorFromPage();
+
+ info("Click in the page and make sure a color-picked event is received");
+ let onColorPicked = waitForEvent("color-picked");
+ win.document.body.click();
+ let color = yield onColorPicked;
+
+ is(color, "#000000", "The color-picked event was received with the right color");
+
+ runNextTest();
+ });
+
+ addAsyncTest(function*() {
+ info("Start picking a color from the page");
+ yield inspector.pickColorFromPage();
+
+ info("Use the escape key to dismiss the eyedropper");
+ let onPickCanceled = waitForEvent("color-pick-canceled");
+
+ let keyboardEvent = win.document.createEvent("KeyboardEvent");
+ keyboardEvent.initKeyEvent("keydown", true, true, win, false, false,
+ false, false, 27, 0);
+ win.document.dispatchEvent(keyboardEvent);
+
+ yield onPickCanceled;
+ ok(true, "The color-pick-canceled event was received");
+
+ runNextTest();
+ });
+
+ addAsyncTest(function*() {
+ info("Start picking a color from the page");
+ yield inspector.pickColorFromPage();
+
+ info("And cancel the color picking");
+ yield inspector.cancelPickColorFromPage();
+
+ runNextTest();
+ });
+
+ function waitForEvent(name) {
+ return new Promise(resolve => inspector.once(name, resolve));
+ }
+
+ runNextTest();
+};
+ </script>
+</head>
+<body>
+<a id="inspectorContent" target="_blank" href="inspector-eyedropper.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none"></div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/mochitest/test_inspector-pseudoclass-lock.html b/devtools/server/tests/mochitest/test_inspector-pseudoclass-lock.html new file mode 100644 index 000000000..64bb03f80 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-pseudoclass-lock.html @@ -0,0 +1,174 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); +const DOMUtils = Components.classes["@mozilla.org/inspector/dom-utils;1"]. + getService(Components.interfaces.inIDOMUtils); + +const KNOWN_PSEUDOCLASSES = [':hover', ':active', ':focus'] + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gInspectee = null; +var gWalker = null; +var gClient = null; +var gCleanupConnection = null; + +function setup(callback) { + let url = document.getElementById("inspectorContent").href; + gCleanupConnection = attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + gClient = client; + gWalker = walker; + }).then(callback)); + }); +} + +function teardown() { + gWalker = null; + gClient = null; + gInspectee = null; + if (gCleanupConnection) { + gCleanupConnection(); + gCleanupConnection = null; + } +} + +function checkChange(change, expectation) { + is(change.type, "pseudoClassLock", "Expect a pseudoclass lock change."); + let target = change.target; + if (expectation.id) + is(target.id, expectation.id, "Expect a change on node id " + expectation.id); + if (expectation.nodeName) + is(target.nodeName, expectation.nodeName, "Expect a change on node name " + expectation.nodeName); + + is(target.pseudoClassLocks.length, expectation.pseudos.length, + "Expect " + expectation.pseudos.length + " pseudoclass locks."); + for (let pseudo of expectation.pseudos) { + ok(target.hasPseudoClassLock(pseudo), "Expect lock: " + pseudo); + ok(DOMUtils.hasPseudoClassLock(target.rawNode(), pseudo), "Expect lock in dom: " + pseudo); + } + + for (let pseudo of KNOWN_PSEUDOCLASSES) { + if (!expectation.pseudos.some(expected => pseudo === expected)) { + ok(!target.hasPseudoClassLock(pseudo), "Don't expect lock: " + pseudo); + ok(!DOMUtils.hasPseudoClassLock(target.rawNode(), pseudo), "Don't expect lock in dom: " + pseudo); + + } + } +} + +function checkMutations(mutations, expectations) { + is(mutations.length, expectations.length, "Should get the right number of mutations."); + for (let i = 0; i < mutations.length; i++) { + checkChange(mutations[i] , expectations[i]); + } +} + +addTest(function testPseudoClassLock() { + let contentNode; + let nodeFront; + setup(() => { + contentNode = gInspectee.querySelector("#b"); + return promiseDone(gWalker.querySelector(gWalker.rootNode, "#b").then(front => { + nodeFront = front; + // Lock the pseudoclass alone, no parents. + gWalker.addPseudoClassLock(nodeFront, ':active'); + // Expect a single pseudoClassLock mutation. + return promiseOnce(gWalker, "mutations"); + }).then(mutations => { + is(mutations.length, 1, "Should get one mutations"); + is(mutations[0].target, nodeFront, "Should be the node we tried to apply to"); + checkChange(mutations[0], { + id: "b", + nodeName: "DIV", + pseudos: [":active"] + }); + }).then(() => { + // Now add :hover, this time with parents. + gWalker.addPseudoClassLock(nodeFront, ':hover', {parents: true}); + return promiseOnce(gWalker, "mutations"); + }).then(mutations => { + let expectedMutations = [{ + id: 'b', + nodeName: 'DIV', + pseudos: [':hover', ':active'], + }, + { + id: 'longlist', + nodeName: 'DIV', + pseudos: [':hover'] + }, + { + nodeName: 'BODY', + pseudos: [':hover'] + }, + { + nodeName: 'HTML', + pseudos: [':hover'] + }]; + checkMutations(mutations, expectedMutations); + }).then(() => { + // Now remove the :hover on all parents + gWalker.removePseudoClassLock(nodeFront, ':hover', {parents: true}); + return promiseOnce(gWalker, "mutations"); + }).then(mutations => { + let expectedMutations = [{ + id: 'b', + nodeName: 'DIV', + // Should still have :active on the original node. + pseudos: [':active'] + }, + { + id: 'longlist', + nodeName: 'DIV', + pseudos: [] + }, + { + nodeName: 'BODY', + pseudos: [] + }, + { + nodeName: 'HTML', + pseudos: [] + }]; + checkMutations(mutations, expectedMutations); + }).then(() => { + // Now shut down the walker and make sure that clears up the remaining lock. + return gWalker.release(); + }).then(() => { + ok(!DOMUtils.hasPseudoClassLock(contentNode, ':active'), "Pseudoclass should have been removed during destruction."); + teardown(); + }).then(runNextTest)); + }); +}); + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-release.html b/devtools/server/tests/mochitest/test_inspector-release.html new file mode 100644 index 000000000..45412bef0 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-release.html @@ -0,0 +1,102 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gWalker = null; +var gClient = null; + +function assertOwnership() { + return assertOwnershipTrees(gWalker); +} + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + }).then(runNextTest)); + }); +}); + +addTest(function testReleaseSubtree() { + let originalOwnershipSize = 0; + let longlist = null; + let firstChild = null; + promiseDone(gWalker.querySelectorAll(gWalker.rootNode, "#longlist div").then(list => { + // Make sure we have the 26 children of longlist in our ownership tree. + is(list.length, 26, "Expect 26 div children."); + // Make sure we've read in all those children and incorporated them in our ownership tree. + return list.items(); + }).then((items)=> { + originalOwnershipSize = assertOwnership(); + + // Here is how the ownership tree is summed up: + // #document 1 + // <html> 1 + // <body> 1 + // <div id=longlist> 1 + // <div id=a>a</div> 26*2 (each child plus it's singleTextChild) + // ... + // <div id=z>z</div> + // ----- + // 56 + is(originalOwnershipSize, 56, "Correct number of items in ownership tree"); + firstChild = items[0].actorID; + }).then(() => { + // Now get the longlist and release it from the ownership tree. + return gWalker.querySelector(gWalker.rootNode, "#longlist"); + }).then(node => { + longlist = node.actorID; + return gWalker.releaseNode(node); + }).then(() => { + // Our ownership size should now be 53 fewer (we forgot about #longlist + 26 children + 26 singleTextChild nodes) + let newOwnershipSize = assertOwnership(); + is(newOwnershipSize, originalOwnershipSize - 53, + "Ownership tree should be lower"); + // Now verify that some nodes have gone away + return checkMissing(gClient, longlist); + }).then(() => { + return checkMissing(gClient, firstChild); + }).then(runNextTest)); +}); + +addTest(function cleanup() { + delete gWalker; + delete gClient; + runNextTest(); +}); + + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-reload.html b/devtools/server/tests/mochitest/test_inspector-reload.html new file mode 100644 index 000000000..91252aa8f --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-reload.html @@ -0,0 +1,80 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gInspectee = null; +var gClient = null; +var gWalker = null; + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + return inspector.getWalker(); + }).then(walker => { + dump(walker.actorID + "\n"); + ok(walker === gWalker, "getWalker() twice should return the same walker."); + }).then(runNextTest)); + }); +}); + +addTest(function testReload() { + let nodeFront; + let oldRootID = gWalker.rootNode.actorID; + // Load a node to populate the tree a bit. + promiseDone(gWalker.querySelector(gWalker.rootNode, "#a").then(front => { + gInspectee.defaultView.location.reload(); + return waitForMutation(gWalker, isNewRoot); + }).then(() => { + ok(gWalker.rootNode.actorID != oldRootID, "Root node should have changed."); + }).then(() => { + // Make sure we can still access the document + return gWalker.querySelector(gWalker.rootNode, "#a"); + }).then(front => { + ok(front.actorID, "Got a new actor ID"); + }).then(runNextTest)); +}); + +addTest(function cleanup() { + delete gWalker; + delete gInspectee; + delete gClient; + runNextTest(); +}); + + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-remove.html b/devtools/server/tests/mochitest/test_inspector-remove.html new file mode 100644 index 000000000..2331c3e30 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-remove.html @@ -0,0 +1,117 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gWalker = null; +var gClient = null; + +function assertOwnership() { + return assertOwnershipTrees(gWalker); +} + +function ignoreNode(node) { + // Duplicate the walker logic to skip blank nodes... + return node.nodeType === Components.interfaces.nsIDOMNode.TEXT_NODE && + !/[^\s]/.test(node.nodeValue); +} + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + }).then(runNextTest)); + }); +}); + +addTest(function testRemoveSubtree() { + let originalOwnershipSize = 0; + let longlist = null; + let longlistID = null; + + let nextSibling = gInspectee.querySelector("#longlist").nextSibling; + while (nextSibling && ignoreNode(nextSibling)) { + nextSibling = nextSibling.nextSibling; + } + + let previousSibling = gInspectee.querySelector("#longlist").previousSibling; + while (previousSibling && ignoreNode(previousSibling)) { + previousSibling = previousSibling.previousSibling; + } + + promiseDone(gWalker.querySelector(gWalker.rootNode, "#longlist").then(listFront => { + longlist = listFront; + longlistID = longlist.actorID; + }).then(() => { + return gWalker.children(longlist); + }).then((items)=> { + originalOwnershipSize = assertOwnership(); + // Here is how the ownership tree is summed up: + // #document 1 + // <html> 1 + // <body> 1 + // <div id=longlist> 1 + // <div id=a>a</div> 26*2 (each child plus it's singleTextChild) + // ... + // <div id=z>z</div> + // ----- + // 56 + is(originalOwnershipSize, 56, "Correct number of items in ownership tree"); + return gWalker.removeNode(longlist); + }).then(siblings => { + is(siblings.previousSibling.rawNode(), previousSibling, "Should have returned the previous sibling."); + is(siblings.nextSibling.rawNode(), nextSibling, "Should have returned the next sibling."); + return waitForMutation(gWalker, isChildList); + }).then(() => { + // Our ownership size should now be 51 fewer (we forgot about #longlist + 26 + // children + 26 singleTextChild nodes, but learned about #longlist's + // prev/next sibling) + let newOwnershipSize = assertOwnership(); + is(newOwnershipSize, originalOwnershipSize - 51, + "Ownership tree should be lower"); + // Now verify that some nodes have gone away + return checkMissing(gClient, longlistID); + }).then(runNextTest)); +}); + +addTest(function cleanup() { + delete gWalker; + delete gClient; + runNextTest(); +}); + + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-resize.html b/devtools/server/tests/mochitest/test_inspector-resize.html new file mode 100644 index 000000000..eafa6436c --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-resize.html @@ -0,0 +1,77 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test that the inspector actor emits "resize" events when the page is resized. +https://bugzilla.mozilla.org/show_bug.cgi?id=1222409 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 1222409</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +window.onload = function() { + const Cu = Components.utils; + const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); + const promise = require("promise"); + const {InspectorFront} = require("devtools/shared/fronts/inspector"); + const {console} = Cu.import("resource://gre/modules/Console.jsm", {}); + + SimpleTest.waitForExplicitFinish(); + + let win = null; + let inspector = null; + + addAsyncTest(function* setup() { + info ("Setting up inspector and walker actors."); + + let url = document.getElementById("inspectorContent").href; + + yield new promise(resolve => { + attachURL(url, function(err, client, tab, doc) { + win = doc.defaultView; + inspector = InspectorFront(client, tab); + resolve(); + }); + }); + + runNextTest(); + }); + + addAsyncTest(function*() { + let walker = yield inspector.getWalker(); + + // We can't receive events from the walker if we haven't first executed a + // method on the actor to initialize it. + yield walker.querySelector(walker.rootNode, "img"); + + let {outerWidth, outerHeight} = win; + let onResize = new promise(resolve => { + walker.once("resize", () => { + resolve(); + }); + }); + win.resizeTo(800, 600); + yield onResize; + + ok(true, "The resize event was emitted"); + win.resizeTo(outerWidth, outerHeight); + + runNextTest(); + }); + + runNextTest(); +}; + </script> +</head> +<body> +<a id="inspectorContent" target="_blank" href="inspector-search-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-resolve-url.html b/devtools/server/tests/mochitest/test_inspector-resolve-url.html new file mode 100644 index 000000000..1494739ed --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-resolve-url.html @@ -0,0 +1,88 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=921102 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 921102</title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gInspector; +var gDoc; + +addTest(function() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gDoc = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + gInspector = InspectorFront(client, tab); + runNextTest(); + }); +}); + +addTest(function() { + info("Resolve a relative URL without providing a context node"); + gInspector.resolveRelativeURL("test.png?id=4#wow").then(url => { + is(url, "chrome://mochitests/content/chrome/devtools/server/tests/" + + "mochitest/test.png?id=4#wow"); + runNextTest(); + }); +}); + +addTest(function() { + info("Resolve an absolute URL without providing a context node"); + gInspector.resolveRelativeURL("chrome://mochitests/content/chrome/" + + "devtools/server/").then(url => { + is(url, "chrome://mochitests/content/chrome/devtools/server/"); + runNextTest(); + }); +}); + +addTest(function() { + info("Resolve a relative URL providing a context node"); + let node = gDoc.querySelector(".big-horizontal"); + gInspector.resolveRelativeURL("test.png?id=4#wow", node).then(url => { + is(url, "chrome://mochitests/content/chrome/devtools/server/tests/" + + "mochitest/test.png?id=4#wow"); + runNextTest(); + }); +}); + +addTest(function() { + info("Resolve an absolute URL providing a context node"); + let node = gDoc.querySelector(".big-horizontal"); + gInspector.resolveRelativeURL("chrome://mochitests/content/chrome/" + + "devtools/server/", node).then(url => { + is(url, "chrome://mochitests/content/chrome/devtools/server/"); + runNextTest(); + }); +}); + +addTest(function() { + gInspector = gDoc = null; + runNextTest(); +}); + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=921102">Mozilla Bug 921102</a> +<a id="inspectorContent" target="_blank" href="inspector_getImageData.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-retain.html b/devtools/server/tests/mochitest/test_inspector-retain.html new file mode 100644 index 000000000..e8342cf67 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-retain.html @@ -0,0 +1,180 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gWalker = null; +var gClient = null; +var gInspectee = null; + +function assertOwnership() { + return assertOwnershipTrees(gWalker); +} + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + }).then(runNextTest)); + }); +}); + +// Retain a node, and a second-order child (in another document, for kicks) +// Release the parent of the top item, which should cause one retained orphan. + +// Then unretain the top node, which should retain the orphan. + +// Then change the source of the iframe, which should kill that orphan. + +addTest(function testRetain() { + let originalOwnershipSize = 0; + let bodyFront = null; + let frameFront = null; + let childListFront = null; + // Get the toplevel body element and retain it. + promiseDone(gWalker.querySelector(gWalker.rootNode, "body").then(front => { + bodyFront = front; + return gWalker.retainNode(bodyFront); + }).then(() => { + // Get an element in the child frame and retain it. + return gWalker.querySelector(gWalker.rootNode, "#childFrame"); + }).then(frame => { + frameFront = frame; + return gWalker.children(frame, { maxNodes: 1 }).then(children => { + return children.nodes[0]; + }); + }).then(childDoc => { + return gWalker.querySelector(childDoc, "#longlist"); + }).then(list => { + childListFront = list; + originalOwnershipSize = assertOwnership(); + // and rtain it. + return gWalker.retainNode(childListFront); + }).then(() => { + // OK, try releasing the parent of the first retained. + return gWalker.releaseNode(bodyFront.parentNode()); + }).then(() => { + let size = assertOwnership(); + let clientTree = clientOwnershipTree(gWalker); + + // That request should have freed the parent of the first retained + // but moved the rest into the retained orphaned tree. + is(ownershipTreeSize(clientTree.root) + ownershipTreeSize(clientTree.retained[0]) + 1, + originalOwnershipSize, + "Should have only lost one item overall."); + is(gWalker._retainedOrphans.size, 1, "Should have retained one orphan"); + ok(gWalker._retainedOrphans.has(bodyFront), "Should have retained the expected node."); + }).then(() => { + // Unretain the body, which should promote the childListFront to a retained orphan. + return gWalker.unretainNode(bodyFront); + }).then(() => { + assertOwnership(); + let clientTree = clientOwnershipTree(gWalker); + + is(gWalker._retainedOrphans.size, 1, "Should still only have one retained orphan."); + ok(!gWalker._retainedOrphans.has(bodyFront), "Should have dropped the body node.") + ok(gWalker._retainedOrphans.has(childListFront), "Should have retained the child node.") + }).then(() => { + // Change the source of the iframe, which should kill the retained orphan. + gInspectee.querySelector("#childFrame").src = "data:text/html,<html>new child</html>"; + return waitForMutation(gWalker, isUnretained); + }).then(mutations => { + assertOwnership(); + let clientTree = clientOwnershipTree(gWalker); + is(gWalker._retainedOrphans.size, 0, "Should have no more retained orphans."); + + }).then(runNextTest)); +}); + +// Get a hold of a node, remove it from the doc and retain it at the same time. +// We should always win that race (even though the mutation happens before the +// retain request), because we haven't issued `getMutations` yet. +addTest(function testWinRace() { + let front = null; + promiseDone(gWalker.querySelector(gWalker.rootNode, "#a").then(node => { + front = node; + let contentNode = gInspectee.querySelector("#a"); + contentNode.parentNode.removeChild(contentNode); + // Now wait for that mutation and retain response to come in. + return promise.all([ + gWalker.retainNode(front), + waitForMutation(gWalker, isChildList) + ]); + }).then(() => { + assertOwnership(); + let clientTree = clientOwnershipTree(gWalker); + is(gWalker._retainedOrphans.size, 1, "Should have a retained orphan."); + ok(gWalker._retainedOrphans.has(front), "Should have retained our expected node."); + return gWalker.unretainNode(front); + }).then(() => { + // Make sure we're clear for the next test. + assertOwnership(); + let clientTree = clientOwnershipTree(gWalker); + is(gWalker._retainedOrphans.size, 0, "Should have no more retained orphans."); + }).then(runNextTest)); +}); + +// Same as above, but issue the request right after the 'new-mutations' event, so that +// we *lose* the race. +addTest(function testLoseRace() { + let front = null; + promiseDone(gWalker.querySelector(gWalker.rootNode, "#z").then(node => { + front = node; + gInspectee.querySelector("#z").parentNode = null; + let contentNode = gInspectee.querySelector("#a"); + contentNode.parentNode.removeChild(contentNode); + return promiseOnce(gWalker, "new-mutations"); + }).then(() => { + // Verify that we have an outstanding request (no good way to tell that it's a + // getMutations request, but there's nothing else it would be). + is(gWalker._requests.length, 1, "Should have an outstanding request."); + return gWalker.retainNode(front) + }).then(() => { ok(false, "Request should not have succeeded!"); }, + (err) => { + ok(err, "noSuchActor", "Should have lost the race."); + let clientTree = clientOwnershipTree(gWalker); + is(gWalker._retainedOrphans.size, 0, "Should have no more retained orphans."); + // Don't re-throw the error. + }).then(runNextTest)); +}); + +addTest(function cleanup() { + delete gWalker; + delete gClient; + runNextTest(); +}); + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-scroll-into-view.html b/devtools/server/tests/mochitest/test_inspector-scroll-into-view.html new file mode 100644 index 000000000..1e164e83d --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-scroll-into-view.html @@ -0,0 +1,87 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=901250 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 901250</title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gInspectee = null; +var gClient = null; +var gWalker = null; + +function assertOwnership() { + assertOwnershipTrees(gWalker); +} + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + }).then(runNextTest)); + }); +}); + +addTest(Task.async(function* testScrollIntoView() { + let id = "#scroll-into-view"; + let rect = gInspectee.querySelector(id).getBoundingClientRect(); + let nodeFront = yield gWalker.querySelector(gWalker.rootNode, id); + let inViewport = rect.x >= 0 && + rect.y >= 0 && + rect.y <= gInspectee.defaultView.innerHeight && + rect.x <= gInspectee.defaultView.innerWidth; + + ok(!inViewport, "Element is not in viewport."); + + yield nodeFront.scrollIntoView(); + + SimpleTest.executeSoon(() => { + rect = gInspectee.querySelector(id).getBoundingClientRect(); + inViewport = rect.x >= 0 && + rect.y >= 0 && + rect.y <= gInspectee.defaultView.innerHeight && + rect.x <= gInspectee.defaultView.innerWidth; + + ok(inViewport, "Element is in viewport."); + + runNextTest(); + }); +})); + +addTest(function cleanup() { + delete gWalker; + delete gInspectee; + delete gClient; + runNextTest(); +}); + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=901250">Mozilla Bug 901250</a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-search-front.html b/devtools/server/tests/mochitest/test_inspector-search-front.html new file mode 100644 index 000000000..e0f8f77e8 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-search-front.html @@ -0,0 +1,217 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=835896 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 835896</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +window.onload = function() { + const Cu = Components.utils; + const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); + const promise = require("promise"); + const {InspectorFront} = require("devtools/shared/fronts/inspector"); + const {console} = Cu.import("resource://gre/modules/Console.jsm", {}); + + SimpleTest.waitForExplicitFinish(); + + let walkerFront = null; + let inspectee = null; + let inspector = null; + + // WalkerFront specific tests. These aren't to excercise search + // edge cases so much as to test the state the Front maintains between + // searches. + // See also test_inspector-search.html + + addAsyncTest(function* setup() { + info ("Setting up inspector and walker actors."); + + let url = document.getElementById("inspectorContent").href; + + yield new promise(resolve => { + attachURL(url, function(err, client, tab, doc) { + inspectee = doc; + inspector = InspectorFront(client, tab); + resolve(); + }); + }); + + walkerFront = yield inspector.getWalker(); + ok(walkerFront, "getWalker() should return an actor."); + + runNextTest(); + }); + + addAsyncTest(function* testWalkerFrontDefaults() { + info ("Testing search API using WalkerFront."); + let nodes = yield walkerFront.querySelectorAll(walkerFront.rootNode, "h2"); + let fronts = yield nodes.items(); + + let frontResult = yield walkerFront.search(""); + ok(!frontResult, "Null result on front when searching for ''"); + + let results = yield walkerFront.search("h2"); + isDeeply(results, { + node: fronts[0], + type: "search", + resultsIndex: 0, + resultsLength: 3 + }, "Default options work"); + + results = yield walkerFront.search("h2", { }); + isDeeply(results, { + node: fronts[1], + type: "search", + resultsIndex: 1, + resultsLength: 3 + }, "Search works with empty options"); + + // Clear search data to remove result state on the front + yield walkerFront.search(""); + runNextTest(); + }); + + addAsyncTest(function* testMultipleSearches() { + info ("Testing search API using WalkerFront (reverse=false)"); + let nodes = yield walkerFront.querySelectorAll(walkerFront.rootNode, "h2"); + let fronts = yield nodes.items(); + + let results = yield walkerFront.search("h2"); + isDeeply(results, { + node: fronts[0], + type: "search", + resultsIndex: 0, + resultsLength: 3 + }, "Search works with multiple results (reverse=false)"); + + results = yield walkerFront.search("h2"); + isDeeply(results, { + node: fronts[1], + type: "search", + resultsIndex: 1, + resultsLength: 3 + }, "Search works with multiple results (reverse=false)"); + + results = yield walkerFront.search("h2"); + isDeeply(results, { + node: fronts[2], + type: "search", + resultsIndex: 2, + resultsLength: 3 + }, "Search works with multiple results (reverse=false)"); + + results = yield walkerFront.search("h2"); + isDeeply(results, { + node: fronts[0], + type: "search", + resultsIndex: 0, + resultsLength: 3 + }, "Search works with multiple results (reverse=false)"); + + // Clear search data to remove result state on the front + yield walkerFront.search(""); + runNextTest(); + }); + + addAsyncTest(function* testMultipleSearchesReverse() { + info ("Testing search API using WalkerFront (reverse=true)"); + let nodes = yield walkerFront.querySelectorAll(walkerFront.rootNode, "h2"); + let fronts = yield nodes.items(); + + let results = yield walkerFront.search("h2", {reverse: true}); + isDeeply(results, { + node: fronts[2], + type: "search", + resultsIndex: 2, + resultsLength: 3 + }, "Search works with multiple results (reverse=true)"); + + results = yield walkerFront.search("h2", {reverse: true}); + isDeeply(results, { + node: fronts[1], + type: "search", + resultsIndex: 1, + resultsLength: 3 + }, "Search works with multiple results (reverse=true)"); + + results = yield walkerFront.search("h2", {reverse: true}); + isDeeply(results, { + node: fronts[0], + type: "search", + resultsIndex: 0, + resultsLength: 3 + }, "Search works with multiple results (reverse=true)"); + + results = yield walkerFront.search("h2", {reverse: true}); + isDeeply(results, { + node: fronts[2], + type: "search", + resultsIndex: 2, + resultsLength: 3 + }, "Search works with multiple results (reverse=true)"); + + results = yield walkerFront.search("h2", {reverse: false}); + isDeeply(results, { + node: fronts[0], + type: "search", + resultsIndex: 0, + resultsLength: 3 + }, "Search works with multiple results (reverse=false)"); + + // Clear search data to remove result state on the front + yield walkerFront.search(""); + runNextTest(); + }); + + + addAsyncTest(function* testBackwardsCompat() { + info ("Simulating a server that doesn't have the new search functionality."); + walkerFront.traits.textSearch = false; + let front = yield walkerFront.querySelector(walkerFront.rootNode, "h1"); + + let results = yield walkerFront.search("h1"); + isDeeply(results, { + node: front, + type: "selector", + resultsIndex: 0, + resultsLength: 1 + }, "Only querySelectorAll results being returned"); + + // Clear search data to remove result state on the front + yield walkerFront.search(""); + + // Reset the normal textSearch behavior + walkerFront.traits.textSearch = true; + + results = yield walkerFront.search("h1"); + isDeeply(results, { + node: front, + type: "search", + resultsIndex: 0, + resultsLength: 3 + }, "Other results being included"); + + // Clear search data to remove result state on the front + yield walkerFront.search(""); + runNextTest(); + }); + + runNextTest(); +}; + </script> +</head> +<body> +<a id="inspectorContent" target="_blank" href="inspector-search-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-search.html b/devtools/server/tests/mochitest/test_inspector-search.html new file mode 100644 index 000000000..623d3018d --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-search.html @@ -0,0 +1,296 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=835896 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 835896</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +window.onload = function() { + const Cu = Components.utils; + const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); + const promise = require("promise"); + const {InspectorFront} = require("devtools/shared/fronts/inspector"); + const {WalkerSearch, WalkerIndex} = + require("devtools/server/actors/utils/walker-search"); + const {console} = Cu.import("resource://gre/modules/Console.jsm", {}); + + SimpleTest.waitForExplicitFinish(); + + let walkerActor = null; + let walkerSearch = null; + let inspectee = null; + let inspector = null; + + // WalkerSearch specific tests. This is to make sure search results are + // coming back as expected. + // See also test_inspector-search-front.html. + + addAsyncTest(function* setup() { + info ("Setting up inspector and walker actors."); + + let url = document.getElementById("inspectorContent").href; + + yield new promise(resolve => { + attachURL(url, function(err, client, tab, doc) { + inspectee = doc; + inspector = InspectorFront(client, tab); + resolve(); + }); + }); + + let walkerFront = yield inspector.getWalker(); + ok(walkerFront, "getWalker() should return an actor."); + + walkerActor = DebuggerServer._searchAllConnectionsForActor(walkerFront.actorID); + ok(walkerActor, + "Got a reference to the walker actor (" + walkerFront.actorID + ")"); + + walkerSearch = walkerActor.walkerSearch; + + runNextTest(); + }); + + addAsyncTest(function* testIndexExists() { + info ("Testing basic index APIs exist."); + + let index = new WalkerIndex(walkerActor); + ok(index.data.size > 0, "public index is filled after getting"); + + index.clearIndex(); + ok(!index._data, "private index is empty after clearing"); + ok(index.data.size > 0, "public index is filled after getting"); + + index.destroy(); + runNextTest(); + }); + + addAsyncTest(function* testSearchExists() { + info ("Testing basic search APIs exist."); + + ok(walkerSearch, "walker search exists on the WalkerActor"); + ok(walkerSearch.search, "walker search has `search` method"); + ok(walkerSearch.index, "walker search has `index` property"); + is(walkerSearch.walker, walkerActor, "referencing the correct WalkerActor"); + + let search = new WalkerSearch(walkerActor); + ok(search, "a new search instance can be created"); + ok(search.search, "new search instance has `search` method"); + ok(search.index, "new search instance has `index` property"); + isnot(search, walkerSearch, "new search instance differs from the WalkerActor's"); + + search.destroy(); + runNextTest(); + }); + + addAsyncTest(function* testEmptySearch() { + info ("Testing search with an empty query."); + results = walkerSearch.search(""); + is(results.length, 0, "No results when searching for ''"); + + results = walkerSearch.search(null); + is(results.length, 0, "No results when searching for null"); + + results = walkerSearch.search(undefined); + is(results.length, 0, "No results when searching for undefined"); + + results = walkerSearch.search(10); + is(results.length, 0, "No results when searching for 10"); + + runNextTest(); + }); + + addAsyncTest(function* testBasicSearchData() { + let testData = [ + { + desc: "Search for tag with one result.", + search: "body", + expected: [ + {node: inspectee.body, type: "tag"} + ] + }, + { + desc: "Search for tag with multiple results", + search: "h2", + expected: [ + {node: inspectee.querySelectorAll("h2")[0], type: "tag"}, + {node: inspectee.querySelectorAll("h2")[1], type: "tag"}, + {node: inspectee.querySelectorAll("h2")[2], type: "tag"}, + ] + }, + { + desc: "Search for selector with multiple results", + search: "body > h2", + expected: [ + {node: inspectee.querySelectorAll("h2")[0], type: "selector"}, + {node: inspectee.querySelectorAll("h2")[1], type: "selector"}, + {node: inspectee.querySelectorAll("h2")[2], type: "selector"}, + ] + }, + { + desc: "Search for selector with multiple results", + search: ":root h2", + expected: [ + {node: inspectee.querySelectorAll("h2")[0], type: "selector"}, + {node: inspectee.querySelectorAll("h2")[1], type: "selector"}, + {node: inspectee.querySelectorAll("h2")[2], type: "selector"}, + ] + }, + { + desc: "Search for selector with multiple results", + search: "* h2", + expected: [ + {node: inspectee.querySelectorAll("h2")[0], type: "selector"}, + {node: inspectee.querySelectorAll("h2")[1], type: "selector"}, + {node: inspectee.querySelectorAll("h2")[2], type: "selector"}, + ] + }, + { + desc: "Search with multiple matches in a single tag expecting a single result", + search: "💩", + expected: [ + {node: inspectee.getElementById("💩"), type: "attributeValue"} + ] + }, + { + desc: "Search that has tag and text results", + search: "h1", + expected: [ + {node: inspectee.querySelector("h1"), type: "tag"}, + {node: inspectee.querySelector("h1 + p").childNodes[0], type: "text"}, + {node: inspectee.querySelector("h1 + p > strong").childNodes[0], type: "text"}, + ] + }, + ] + + for (let {desc, search, expected} of testData) { + info("Running test: " + desc); + let results = walkerSearch.search(search); + isDeeply(results, expected, + "Search returns correct results with '" + search + "'"); + } + + runNextTest(); + }); + + addAsyncTest(function* testPseudoElements() { + info ("Testing ::before and ::after element matching"); + + let beforeElt = new _documentWalker(inspectee.querySelector("#pseudo"), + inspectee.defaultView).firstChild(); + let afterElt = new _documentWalker(inspectee.querySelector("#pseudo"), + inspectee.defaultView).lastChild(); + let styleText = inspectee.querySelector("style").childNodes[0]; + + // ::before + let results = walkerSearch.search("::before"); + isDeeply(results, [ {node: beforeElt, type: "tag"} ], + "Tag search works for pseudo element"); + + results = walkerSearch.search("_moz_generated_content_before"); + is(results.length, 0, "No results for anon tag name"); + + results = walkerSearch.search("before element"); + isDeeply(results, [ + {node: styleText, type: "text"}, + {node: beforeElt, type: "text"} + ], "Text search works for pseudo element"); + + // ::after + results = walkerSearch.search("::after"); + isDeeply(results, [ {node: afterElt, type: "tag"} ], + "Tag search works for pseudo element"); + + results = walkerSearch.search("_moz_generated_content_after"); + is(results.length, 0, "No results for anon tag name"); + + results = walkerSearch.search("after element"); + isDeeply(results, [ + {node: styleText, type: "text"}, + {node: afterElt, type: "text"} + ], "Text search works for pseudo element"); + + runNextTest(); + }); + + addAsyncTest(function* testSearchMutationChangeResults() { + info ("Testing search before and after a mutation."); + let expected = [ + {node: inspectee.querySelectorAll("h3")[0], type: "tag"}, + {node: inspectee.querySelectorAll("h3")[1], type: "tag"}, + {node: inspectee.querySelectorAll("h3")[2], type: "tag"}, + ]; + + let results = walkerSearch.search("h3"); + isDeeply(results, expected, "Search works with tag results"); + + yield mutateDocumentAndWaitForMutation(() => { + expected[0].node.remove(); + }); + + results = walkerSearch.search("h3"); + isDeeply(results, [ + expected[1], + expected[2] + ], "Results are updated after removal"); + + yield new promise(resolve => { + info("Waiting for a mutation to happen"); + let observer = new inspectee.defaultView.MutationObserver(() => { + resolve(); + }); + observer.observe(inspectee, {attributes: true, subtree: true}); + inspectee.body.setAttribute("h3", "true"); + }); + + results = walkerSearch.search("h3"); + isDeeply(results, [ + {node: inspectee.body, type: "attributeName"}, + expected[1], + expected[2] + ], "Results are updated after addition"); + + yield new promise(resolve => { + info("Waiting for a mutation to happen"); + let observer = new inspectee.defaultView.MutationObserver(() => { + resolve(); + }); + observer.observe(inspectee, {attributes: true, childList: true, subtree: true}); + inspectee.body.removeAttribute("h3"); + expected[1].node.remove(); + expected[2].node.remove(); + }); + + results = walkerSearch.search("h3"); + is(results.length, 0, "Results are updated after removal"); + + runNextTest(); + }); + + runNextTest(); + + function mutateDocumentAndWaitForMutation(mutationFn) { + return new promise(resolve => { + info("Listening to markup mutation on the inspectee"); + let observer = new inspectee.defaultView.MutationObserver(resolve); + observer.observe(inspectee, {childList: true, subtree: true}); + mutationFn(); + }); + } +}; + </script> +</head> +<body> +<a id="inspectorContent" target="_blank" href="inspector-search-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector-traversal.html b/devtools/server/tests/mochitest/test_inspector-traversal.html new file mode 100644 index 000000000..ffac8e915 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector-traversal.html @@ -0,0 +1,354 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/server/actors/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gInspectee = null; +var gClient = null; +var gWalker = null; +var checkActorIDs = []; + +function assertOwnership() { + assertOwnershipTrees(gWalker); +} +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + }).then(runNextTest)); + }); +}); + +addTest(function testWalkerRoot() { + // Make sure that refetching the root document of the walker returns the same + // actor as the getWalker returned. + promiseDone(gWalker.document().then(root => { + ok(root === gWalker.rootNode, "Re-fetching the document node should match the root document node."); + checkActorIDs.push(root.actorID); + assertOwnership(); + }).then(runNextTest)); +}); + +addTest(function testInnerHTML() { + promiseDone(gWalker.documentElement().then(docElement => { + return gWalker.innerHTML(docElement); + }).then(longstring => { + return longstring.string(); + }).then(innerHTML => { + ok(innerHTML === gInspectee.documentElement.innerHTML, "innerHTML should match"); + }).then(runNextTest)); +}); + +addTest(function testOuterHTML() { + promiseDone(gWalker.documentElement().then(docElement => { + return gWalker.outerHTML(docElement); + }).then(longstring => { + return longstring.string(); + }).then(outerHTML => { + ok(outerHTML === gInspectee.documentElement.outerHTML, "outerHTML should match"); + }).then(runNextTest)); +}); + +addTest(function testSetOuterHTMLNode() { + let newHTML = "<p id=\"edit-html-done\">after edit</p>"; + promiseDone(gWalker.querySelector(gWalker.rootNode, "#edit-html").then(node => { + return gWalker.setOuterHTML(node, newHTML); + }).then(() => { + return gWalker.querySelector(gWalker.rootNode, "#edit-html-done"); + }).then(node => { + return gWalker.outerHTML(node); + }).then(longstring => { + return longstring.string(); + }).then(outerHTML => { + is(outerHTML, newHTML, "outerHTML has been updated"); + }).then(() => { + return gWalker.querySelector(gWalker.rootNode, "#edit-html"); + }).then(node => { + ok(!node, "The node with the old ID cannot be selected anymore"); + }).then(runNextTest)); +}); + +addTest(function testQuerySelector() { + promiseDone(gWalker.querySelector(gWalker.rootNode, "#longlist").then(node => { + is(node.getAttribute("data-test"), "exists", "should have found the right node"); + assertOwnership(); + }).then(() => { + return gWalker.querySelector(gWalker.rootNode, "unknownqueryselector").then(node => { + ok(!node, "Should not find a node here."); + assertOwnership(); + }); + }).then(runNextTest)); +}); + +addTest(function testQuerySelectors() { + let nodeList = null; + let firstNode = null; + let nodeListID = null; + promiseDone(gWalker.querySelectorAll(gWalker.rootNode, "#longlist div").then(list => { + nodeList = list; + is(nodeList.length, 26, "Expect 26 div children."); + assertOwnership(); + return nodeList.item(0); + }).then(node => { + firstNode = node; + checkActorIDs.push(node.actorID); + is(node.id, "a", "First child should be a"); + assertOwnership(); + return nodeList.items(); + }).then(nodes => { + is(nodes.length, 26, "Expect 26 nodes"); + is(nodes[0], firstNode, "First node should be reused."); + ok(nodes[0]._parent, "Parent node should be set."); + ok(nodes[0]._next || nodes[0]._prev, "Siblings should be set."); + ok(nodes[25]._next || nodes[25]._prev, "Siblings of " + nodes[25] + " should be set."); + assertOwnership(); + return nodeList.items(-1); + }).then(nodes => { + is(nodes.length, 1, "Expect 1 node") + is(nodes[0].id, "z", "Expect it to be the last node."); + checkActorIDs.push(nodes[0].actorID); + // Save the node list ID so we can ensure it was destroyed. + nodeListID = nodeList.actorID; + assertOwnership(); + return nodeList.release(); + }).then(() => { + ok(!nodeList.actorID, "Actor should have been destroyed."); + assertOwnership(); + return checkMissing(gClient, nodeListID); + }).then(runNextTest)); +}); + +// Helper to check the response of requests that return hasFirst/hasLast/nodes +// node lists (like `children` and `siblings`) +function nodeArrayChecker(first, last, ids) { + return function(response) { + is(response.hasFirst, first, "Should " + (first ? "" : "not ") + " have the first node."); + is(response.hasLast, last, "Should " + (last ? "" : "not ") + " have the last node."); + is(response.nodes.length, ids.length, "Should have " + ids.length + " children listed."); + let responseIds = ''; + for (node of response.nodes) { + responseIds += node.id; + } + is(responseIds, ids, "Correct nodes were returned."); + assertOwnership(); + } +} + +addTest(function testNoChildren() { + promiseDone(gWalker.querySelector(gWalker.rootNode, "#empty").then(empty => { + assertOwnership(); + return gWalker.children(empty).then(nodeArrayChecker(true, true, "")); + }).then(runNextTest)); +}); + +addTest(function testLongListTraversal() { + var longList; + var allChildren; + promiseDone(gWalker.querySelector(gWalker.rootNode, "#longlist").then(node => { + longList = node; + // First call with no options, expect all children. + assertOwnership(); + return gWalker.children(longList).then(response => { + nodeArrayChecker(true, true, "abcdefghijklmnopqrstuvwxyz")(response); + allChildren = response.nodes; + assertOwnership(); + }); + }).then(() => { + // maxNodes should limit us to the first 5 nodes. + assertOwnership(); + return gWalker.children(longList, { maxNodes: 5 }).then(nodeArrayChecker(true, false, 'abcde')); + }).then(() => { + assertOwnership(); + // maxNodes with the second item centered should still give us the first 5 nodes. + return gWalker.children(longList, { maxNodes: 5, center: allChildren[1] }).then( + nodeArrayChecker(true, false, 'abcde') + ); + }).then(() => { + // maxNodes with a center in the middle of the list should put that item in the middle + let center = allChildren[13]; + is(center.id, 'n', "Make sure I know how to count letters."); + return gWalker.children(longList, { maxNodes: 5, center: center }).then( + nodeArrayChecker(false, false, 'lmnop') + ); + }).then(() => { + // maxNodes with the second-to-last item centered should give us the last 5 nodes. + return gWalker.children(longList, { maxNodes: 5, center: allChildren[24] }).then( + nodeArrayChecker(false, true, 'vwxyz') + ); + }).then(() => { + // maxNodes with a start in the middle should start at that node and fetch 5 + let start = allChildren[13]; + is(start.id, 'n', "Make sure I know how to count letters.") + return gWalker.children(longList, { maxNodes: 5, start: start }).then( + nodeArrayChecker(false, false, 'nopqr') + ); + }).then(() => { + // maxNodes near the end should only return what's left + return gWalker.children(longList, { maxNodes: 5, start: allChildren[24] }).then( + nodeArrayChecker(false, true, 'yz') + ); + }).then(runNextTest)); +}); + +addTest(function testObjectNodeChildren() { + promiseDone( + gWalker.querySelector(gWalker.rootNode, "object") + .then(object => gWalker.children(object)) + .then(nodeArrayChecker(true, true, "1")) + .then(runNextTest)); +}); + +addTest(function testSiblings() { + promiseDone(gWalker.querySelector(gWalker.rootNode, "#a").then(a => { + return gWalker.siblings(a, { maxNodes: 5, center: a }).then(nodeArrayChecker(true, false, "abcde")); + }).then(() => { + return gWalker.siblings(gWalker.rootNode).then(response => { + ok(response.hasFirst && response.hasLast, "Has first and last."); + is(response.nodes.length, 1, "Has only the document element."); + ok(response.nodes[0] === gWalker.rootNode, "Document element is its own sibling."); + }); + }).then(runNextTest)); +}); + +addTest(function testNextSibling() { + promiseDone(gWalker.querySelector(gWalker.rootNode, "#y").then(y => { + is(y.id, "y", "Got the right node."); + return gWalker.nextSibling(y); + }).then(z => { + is(z.id, "z", "nextSibling got the next node."); + return gWalker.nextSibling(z); + }).then(nothing => { + is(nothing, null, "nextSibling on the last node returned null."); + }).then(runNextTest)); +}); + +addTest(function testPreviousSibling() { + promiseDone(gWalker.querySelector(gWalker.rootNode, "#b").then(b => { + is(b.id, "b", "Got the right node."); + return gWalker.previousSibling(b); + }).then(a => { + is(a.id, "a", "nextSibling got the next node."); + return gWalker.previousSibling(a); + }).then(nothing => { + is(nothing, null, "previousSibling on the first node returned null."); + }).then(runNextTest)); +}); + + +addTest(function testFrameTraversal() { + promiseDone(gWalker.querySelector(gWalker.rootNode, "#childFrame").then(childFrame => { + return gWalker.children(childFrame); + }).then(children => { + let nodes = children.nodes; + is(nodes.length, 1, "There should be only one child of the iframe"); + is(nodes[0].nodeType, Node.DOCUMENT_NODE, "iframe child should be a document node"); + return gWalker.querySelector(nodes[0], "#z"); + }).then(childDocumentZ => { + return gWalker.parents(childDocumentZ); + }).then(parents => { + // Expected set of parent tag names for this item: + let expectedParents = ['DIV', 'BODY', 'HTML', '#document', 'IFRAME', 'BODY', 'HTML', '#document']; + for (let parent of parents) { + let expected = expectedParents.shift(); + is(parent.nodeName, expected, "Got expected parent"); + } + }).then(runNextTest)); +}); + +addTest(function testLongValue() { + const testSummaryLength = 10; + inspector.setValueSummaryLength(testSummaryLength); + SimpleTest.registerCleanupFunction(function() { + inspector.setValueSummaryLength(inspector.DEFAULT_VALUE_SUMMARY_LENGTH); + }); + + let longstringText = gInspectee.getElementById("longstring").firstChild.nodeValue; + + promiseDone(gWalker.querySelector(gWalker.rootNode, "#longstring").then(node => { + ok(!node.inlineTextChild, "Text is too long to be inlined"); + // Now we need to get the text node child... + return gWalker.children(node, { maxNodes: 1 }); + }).then(children => { + let textNode = children.nodes[0]; + is(textNode.nodeType, Node.TEXT_NODE, "Value should be a text node"); + return textNode; + }).then(textNode => { + return textNode.getNodeValue(); + }).then(value => { + return value.string(); + }).then(valueStr => { + is(valueStr, longstringText, "Full node value should match the string from the document."); + }).then(runNextTest)); +}); + +addTest(function testShortValue() { + let shortstringText = gInspectee.getElementById("shortstring").firstChild.nodeValue; + + promiseDone(gWalker.querySelector(gWalker.rootNode, "#shortstring").then(node => { + ok(!!node.inlineTextChild, "Text is short enough to be inlined"); + // Now we need to get the text node child... + return gWalker.children(node, { maxNodes: 1 }); + }).then(children => { + let textNode = children.nodes[0]; + is(textNode.nodeType, Node.TEXT_NODE, "Value should be a text node"); + return textNode; + }).then(textNode => { + return textNode.getNodeValue(); + }).then(value => { + return value.string(); + }).then(valueStr => { + is(valueStr, shortstringText, "Full node value should match the string from the document."); + }).then(runNextTest)); +}); + +addTest(function testReleaseWalker() { + checkActorIDs.push(gWalker.actorID); + + promiseDone(gWalker.release().then(() => { + let promises = Array.from(checkActorIDs, (id) => checkMissing(gClient, id)); + return promise.all(promises) + }).then(runNextTest)); +}); + +addTest(function cleanup() { + delete gWalker; + delete gInspectee; + delete gClient; + runNextTest(); +}); + + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector_getImageData-wait-for-load.html b/devtools/server/tests/mochitest/test_inspector_getImageData-wait-for-load.html new file mode 100644 index 000000000..63eb0bd3c --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector_getImageData-wait-for-load.html @@ -0,0 +1,136 @@ +<!DOCTYPE HTML> +<html> +<!-- +Tests for InspectorActor.getImageData() in following cases: + * Image takes too long to load (the method rejects after a timeout). + * Image is loading when the method is called and the load finishes before + timeout. + * Image fails to load. + +https://bugzilla.mozilla.org/show_bug.cgi?id=1192536 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 1192536</title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> + +const flags = require("devtools/shared/flags"); +const wasTesting = flags.testing; +SimpleTest.registerCleanupFunction(() => flags.testing = wasTesting); + +const PATH = "http://mochi.test:8888/chrome/devtools/server/tests/mochitest/"; +const BASE_IMAGE = PATH + "inspector-delay-image-response.sjs"; +const DELAYED_IMAGE = BASE_IMAGE + "?delay=300"; +const TIMEOUT_IMAGE = BASE_IMAGE + "?delay=50000"; +const NONEXISTENT_IMAGE = PATH + "this-does-not-exist.png"; + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gImg = null; +var gNodeFront = null; +var gWalker = null; + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + + promiseDone(inspector.getWalker().then(walker => { + gWalker = walker; + return walker.querySelector(gWalker.rootNode, "img.custom").then(img => { + gNodeFront = img; + gImg = doc.querySelector("img.custom"); + + ok(gNodeFront, "Got the image NodeFront."); + ok(gImg, "Got the image Node."); + }); + }).then(runNextTest)); + }); +}); + +addTest(function testTimeout() { + info("Testing that the method aborts if the image takes too long to load."); + + // imageToImageData() only times out when flags.testing is not set. + flags.testing = false; + + gImg.src = TIMEOUT_IMAGE; + + info("Calling getImageData()."); + ensureRejects(gNodeFront.getImageData(), "Timeout image").then(runNextTest); +}); + +addTest(function testNonExistentImage() { + info("Testing that non-existent image causes a rejection."); + + // This test shouldn't hit the timeout. + flags.testing = true; + + gImg.src = NONEXISTENT_IMAGE; + + info("Calling getImageData()."); + ensureRejects(gNodeFront.getImageData(), "Non-existent image").then(runNextTest); +}); + +addTest(function testDelayedImage() { + info("Testing that the method waits for an image to load."); + + // This test shouldn't hit the timeout. + flags.testing = true; + + gImg.src = DELAYED_IMAGE; + + info("Calling getImageData()."); + checkImageData(gNodeFront.getImageData()).then(runNextTest); +}); + +addTest(function cleanup() { + delete gImg; + delete gNodeFront + delete gWalker; + runNextTest(); +}); + +/** + * Asserts that the given promise rejects. + */ +function ensureRejects(promise, desc) { + return promise.then(() => { + ok(false, desc + ": promise resolved unexpectedly."); + }, () => { + ok(true, desc + ": promise rejected as expected."); + }); +} + +/** + * Waits for the call to getImageData() the resolve and checks that the image + * size is reported correctly. + */ +function checkImageData(promise, { width, height } = { width: 1, height: 1 }) { + return promise.then(({ size }) => { + is(size.naturalWidth, width, "The width is correct."); + is(size.naturalHeight, height, "The height is correct."); + }); +} + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1192536">Mozilla Bug 1192536</a> +<a id="inspectorContent" target="_blank" href="inspector_getImageData.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector_getImageData.html b/devtools/server/tests/mochitest/test_inspector_getImageData.html new file mode 100644 index 000000000..be3c24194 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector_getImageData.html @@ -0,0 +1,166 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=932937 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 932937</title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gWalker = null; + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + + promiseDone(inspector.getWalker().then(walker => { + gWalker = walker; + }).then(runNextTest)); + }); +}); + +addTest(function testLargeImage() { + // Select the image node from the test page + gWalker.querySelector(gWalker.rootNode, ".big-horizontal").then(img => { + ok(img, "Image node found in the test page"); + ok(img.getImageData, "Image node has the getImageData function"); + + img.getImageData(100).then(imageData => { + ok(imageData.data, "Image data actor was sent back"); + ok(imageData.size, "Image size info was sent back too"); + is(imageData.size.naturalWidth, 5333, "Natural width of the image correct"); + is(imageData.size.naturalHeight, 3000, "Natural width of the image correct"); + ok(imageData.size.resized, "Image was resized"); + + imageData.data.string().then(str => { + ok(str, "We have an image data string!"); + testResizing(imageData, str); + }); + }); + }); +}); + +addTest(function testLargeCanvas() { + // Select the canvas node from the test page + gWalker.querySelector(gWalker.rootNode, ".big-vertical").then(canvas => { + ok(canvas, "Image node found in the test page"); + ok(canvas.getImageData, "Image node has the getImageData function"); + + canvas.getImageData(350).then(imageData => { + ok(imageData.data, "Image data actor was sent back"); + ok(imageData.size, "Image size info was sent back too"); + is(imageData.size.naturalWidth, 1000, "Natural width of the image correct"); + is(imageData.size.naturalHeight, 2000, "Natural width of the image correct"); + ok(imageData.size.resized, "Image was resized"); + + imageData.data.string().then(str => { + ok(str, "We have an image data string!"); + testResizing(imageData, str); + }); + }); + }); +}); + +addTest(function testSmallImage() { + // Select the small image node from the test page + gWalker.querySelector(gWalker.rootNode, ".small").then(img => { + ok(img, "Image node found in the test page"); + ok(img.getImageData, "Image node has the getImageData function"); + + img.getImageData().then(imageData => { + ok(imageData.data, "Image data actor was sent back"); + ok(imageData.size, "Image size info was sent back too"); + is(imageData.size.naturalWidth, 245, "Natural width of the image correct"); + is(imageData.size.naturalHeight, 240, "Natural width of the image correct"); + ok(!imageData.size.resized, "Image was NOT resized"); + + imageData.data.string().then(str => { + ok(str, "We have an image data string!"); + testResizing(imageData, str); + }); + }); + }); +}); + +addTest(function testDataImage() { + // Select the data image node from the test page + gWalker.querySelector(gWalker.rootNode, ".data").then(img => { + ok(img, "Image node found in the test page"); + ok(img.getImageData, "Image node has the getImageData function"); + + img.getImageData(14).then(imageData => { + ok(imageData.data, "Image data actor was sent back"); + ok(imageData.size, "Image size info was sent back too"); + is(imageData.size.naturalWidth, 28, "Natural width of the image correct"); + is(imageData.size.naturalHeight, 28, "Natural width of the image correct"); + ok(imageData.size.resized, "Image was resized"); + + imageData.data.string().then(str => { + ok(str, "We have an image data string!"); + testResizing(imageData, str); + }); + }); + }); +}); + +addTest(function testNonImgOrCanvasElements() { + gWalker.querySelector(gWalker.rootNode, "body").then(body => { + ensureRejects(body.getImageData(), "Invalid element").then(runNextTest); + }); +}); + +addTest(function cleanup() { + delete gWalker; + runNextTest(); +}); + +/** + * Checks if the server told the truth about resizing the image + */ +function testResizing(imageData, str) { + let img = document.createElement("img"); + img.addEventListener("load", () => { + let resized = !(img.naturalWidth == imageData.size.naturalWidth && + img.naturalHeight == imageData.size.naturalHeight); + is(imageData.size.resized, resized, "Server told the truth about resizing"); + runNextTest(); + }, false); + img.src = str; +} + +/** + * Asserts that the given promise rejects. + */ +function ensureRejects(promise, desc) { + return promise.then(() => { + ok(false, desc + ": promise resolved unexpectedly."); + }, () => { + ok(true, desc + ": promise rejected as expected."); + }); +} + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=932937">Mozilla Bug 932937</a> +<a id="inspectorContent" target="_blank" href="inspector_getImageData.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector_getImageDataFromURL.html b/devtools/server/tests/mochitest/test_inspector_getImageDataFromURL.html new file mode 100644 index 000000000..473a62275 --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector_getImageDataFromURL.html @@ -0,0 +1,114 @@ +<!DOCTYPE HTML> +<html> +<!-- +Tests for InspectorActor.getImageDataFromURL() in following cases: + * Normal case, image loads after a small delay. + * Image takes too long to load (the method rejects after a timeout). + * Image fails to load. + +https://bugzilla.mozilla.org/show_bug.cgi?id=1192536 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 1192536</title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> + +const flags = require("devtools/shared/flags"); +const wasTesting = flags.testing; +SimpleTest.registerCleanupFunction(() => flags.testing = wasTesting); + +const PATH = "http://mochi.test:8888/chrome/devtools/server/tests/mochitest/"; +const BASE_IMAGE = PATH + "inspector-delay-image-response.sjs"; +const DELAYED_IMAGE = BASE_IMAGE + "?delay=300"; +const TIMEOUT_IMAGE = BASE_IMAGE + "?delay=50000"; +const NONEXISTENT_IMAGE = PATH + "this-does-not-exist.png"; + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gInspector = null; + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + gInspector = InspectorFront(client, tab); + runNextTest(); + }); +}); + +addTest(function testTimeout() { + info("Testing that the method aborts if the image takes too long to load."); + + // imageToImageData() only times out when flags.testing is not set. + flags.testing = false; + + ensureRejects(gInspector.getImageDataFromURL(TIMEOUT_IMAGE), + "Image that loads for too long").then(runNextTest); +}); + +addTest(function testNonExistentImage() { + info("Testing that non-existent image causes a rejection."); + + // This test shouldn't hit the timeout. + flags.testing = true; + + ensureRejects(gInspector.getImageDataFromURL(NONEXISTENT_IMAGE), + "Non-existent image").then(runNextTest); +}); + +addTest(function testNormalImage() { + info("Testing that the method waits for an image to load."); + + // This test shouldn't hit the timeout. + flags.testing = true; + + checkImageData(gInspector.getImageDataFromURL(DELAYED_IMAGE)).then(runNextTest); +}); + +addTest(function cleanup() { + delete gInspector; + runNextTest(); +}); + +/** + * Asserts that the given promise rejects. + */ +function ensureRejects(promise, desc) { + return promise.then(() => { + ok(false, desc + ": promise resolved unexpectedly."); + }, () => { + ok(true, desc + ": promise rejected as expected."); + }); +} + +/** + * Waits for the call to getImageData() the resolve and checks that the image + * size is reported correctly. + */ +function checkImageData(promise, { width, height } = { width: 1, height: 1 }) { + return promise.then(({ size }) => { + is(size.naturalWidth, width, "The width is correct."); + is(size.naturalHeight, height, "The height is correct."); + }); +} + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1192536">Mozilla Bug 1192536</a> +<a id="inspectorContent" target="_blank" href="inspector_getImageData.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_inspector_getNodeFromActor.html b/devtools/server/tests/mochitest/test_inspector_getNodeFromActor.html new file mode 100644 index 000000000..6c06d8a7b --- /dev/null +++ b/devtools/server/tests/mochitest/test_inspector_getNodeFromActor.html @@ -0,0 +1,88 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=1155653 +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug 1155653</title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/shared/fronts/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gWalker; + +addTest(function() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + + promiseDone(inspector.getWalker().then(walker => { + gWalker = walker; + }).then(runNextTest)); + }); +}); + +addTest(function() { + info("Try to get a NodeFront from an invalid actorID"); + gWalker.getNodeFromActor("invalid", ["node"]).then(node => { + ok(!node, "The node returned is null"); + runNextTest(); + }); +}); + +addTest(function() { + info("Try to get a NodeFront from a valid actorID but invalid path"); + gWalker.getNodeFromActor(gWalker.actorID, ["invalid", "path"]).then(node => { + ok(!node, "The node returned is null"); + runNextTest(); + }); +}); + +addTest(function() { + info("Try to get a NodeFront from a valid actorID and valid path"); + gWalker.getNodeFromActor(gWalker.actorID, ["rootDoc"]).then(rootDocNode => { + ok(rootDocNode, "A node was returned"); + is(rootDocNode, gWalker.rootNode, "The right node was returned"); + runNextTest(); + }); +}); + +addTest(function() { + info("Try to get a NodeFront from a valid actorID and valid complex path"); + gWalker.getNodeFromActor(gWalker.actorID, + ["tabActor", "window", "document", "body"]).then(bodyNode => { + ok(bodyNode, "A node was returned"); + gWalker.querySelector(gWalker.rootNode, "body").then(node => { + is(bodyNode, node, "The body node was returned"); + runNextTest(); + }); + }); +}); + +addTest(function() { + gWalker = null; + runNextTest(); +}); + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1155653">Mozilla Bug 1155653</a> +<a id="inspectorContent" target="_blank" href="inspector_getImageData.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_makeGlobalObjectReference.html b/devtools/server/tests/mochitest/test_makeGlobalObjectReference.html new file mode 100644 index 000000000..8bd7e0476 --- /dev/null +++ b/devtools/server/tests/mochitest/test_makeGlobalObjectReference.html @@ -0,0 +1,86 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=914405 + +Debugger.prototype.makeGlobalObjectReference should dereference WindowProxy +(outer window) objects. +--> +<head> + <meta charset="utf-8"> + <title>Mozilla Bug 914405</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +Components.utils.import("resource://gre/modules/jsdebugger.jsm"); +addDebuggerToGlobal(this); + +window.onload = function () { + SimpleTest.waitForExplicitFinish(); + + var iframe = document.createElement("iframe"); + iframe.src = "data:text/html,<html>The word 'smorgasbord', spoken by an adorably plump child, symbolizing prosperity</html>"; + iframe.onload = iframeOnLoad; + document.body.appendChild(iframe); + + function iframeOnLoad() { + var dbg = new Debugger; + + var g1o = iframe.contentWindow; // 'o' for 'outer window' + ok(!dbg.hasDebuggee(g1o), "iframe is not initially a debuggee"); + + // Like addDebuggee, makeGlobalObjectReference innerizes. + // 'i' stands for 'inner window'. + // 'DO' stands for 'Debugger.Object'. + var g1iDO = dbg.makeGlobalObjectReference(g1o); + ok(!dbg.hasDebuggee(g1o), "makeGlobalObjectReference does not add g1 as debuggee, designated via outer"); + ok(!dbg.hasDebuggee(g1iDO), "makeGlobalObjectReference does not add g1 as debuggee, designated via D.O "); + + // Wrapping an object automatically outerizes it, so dereferencing an + // inner object D.O gets you an outer object. + // ('===' does distinguish inner and outer objects.) + // (That's a capital '=', if you must know.) + ok(g1iDO.unsafeDereference() === g1o, "g1iDO has the right referent"); + + // However, Debugger.Objects do distinguish inner and outer windows. + var g1oDO = g1iDO.makeDebuggeeValue(g1o); + ok(g1iDO !== g1oDO, "makeDebuggeeValue doesn't innerize"); + ok(g1iDO.unsafeDereference() === g1oDO.unsafeDereference(), + "unsafeDereference() outerizes, so inner and outer window D.Os both dereference to outer"); + + ok(dbg.addDebuggee(g1o) === g1iDO, "addDebuggee returns the inner window's D.O"); + ok(dbg.hasDebuggee(g1o), "addDebuggee adds the correct global"); + ok(dbg.hasDebuggee(g1iDO), "hasDebuggee can take a D.O referring to the inner window"); + ok(dbg.hasDebuggee(g1oDO), "hasDebuggee can take a D.O referring to the outer window"); + + var iframe2 = document.createElement("iframe"); + iframe2.src = "data:text/html,<html>Her retrospection, in hindsight, was prescient.</html>"; + iframe2.onload = iframe2OnLoad; + document.body.appendChild(iframe2); + + function iframe2OnLoad() { + // makeGlobalObjectReference dereferences CCWs. + var g2o = iframe2.contentWindow; + g2o.g1o = g1o; + + var g2iDO = dbg.addDebuggee(g2o); + var g2g1oDO = g2iDO.getOwnPropertyDescriptor('g1o').value; + ok(g2g1oDO !== g1oDO, "g2's cross-compartment wrapper for g1o gets its own D.O"); + ok(g2g1oDO.unwrap() === g1oDO, + "unwrapping g2's cross-compartment wrapper for g1o gets the right D.O"); + ok(dbg.makeGlobalObjectReference(g2g1oDO) === g1iDO, + "makeGlobalObjectReference unwraps cross-compartment wrappers, and innerizes"); + + SimpleTest.finish(); + } + } +} + +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_memory.html b/devtools/server/tests/mochitest/test_memory.html new file mode 100644 index 000000000..9f191da76 --- /dev/null +++ b/devtools/server/tests/mochitest/test_memory.html @@ -0,0 +1,37 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 923275 - Add a memory monitor widget to the developer toolbar +--> +<head> + <meta charset="utf-8"> + <title>Memory monitoring actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="memory-helpers.js" type="application/javascript;version=1.8"></script> +<script> +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + Task.spawn(function* () { + var { memory, client } = yield startServerAndGetSelectedTabMemory(); + var measurement = yield memory.measure(); + ok(measurement.total > 0, "total memory is valid"); + ok(measurement.domSize > 0, "domSize is valid"); + ok(measurement.styleSize > 0, "styleSize is valid"); + ok(measurement.jsObjectsSize > 0, "jsObjectsSize is valid"); + ok(measurement.jsStringsSize > 0, "jsStringsSize is valid"); + ok(measurement.jsOtherSize > 0, "jsOtherSize is valid"); + ok(measurement.otherSize > 0, "otherSize is valid"); + ok(measurement.jsMilliseconds, "jsMilliseconds is valid"); + ok(measurement.nonJSMilliseconds, "nonJSMilliseconds is valid"); + destroyServerAndFinish(client); + }); +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_memory_allocations_01.html b/devtools/server/tests/mochitest/test_memory_allocations_01.html new file mode 100644 index 000000000..2ed9b74bc --- /dev/null +++ b/devtools/server/tests/mochitest/test_memory_allocations_01.html @@ -0,0 +1,98 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1067491 - Test recording allocations. +--> +<head> + <meta charset="utf-8"> + <title>Memory monitoring actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="memory-helpers.js" type="application/javascript;version=1.8"></script> +<script> +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + Task.spawn(function* () { + var { memory, client } = yield startServerAndGetSelectedTabMemory(); + yield memory.attach(); + + yield memory.startRecordingAllocations(); + ok(true, "Can start recording allocations"); + + // Allocate some objects. + + var alloc1, alloc2, alloc3; + (function outer() { + (function middle() { + (function inner() { + alloc1 = {}; alloc1.line = Error().lineNumber; + alloc2 = []; alloc2.line = Error().lineNumber; + alloc3 = new function() {}; alloc3.line = Error().lineNumber; + }()); + }()); + }()); + + var response = yield memory.getAllocations(); + + yield memory.stopRecordingAllocations(); + ok(true, "Can stop recording allocations"); + + // Filter out allocations by library and test code, and get only the + // allocations that occurred in our test case above. + + function isTestAllocation(alloc) { + var frame = response.frames[alloc]; + return frame + && frame.functionDisplayName === "inner" + && (frame.line === alloc1.line + || frame.line === alloc2.line + || frame.line === alloc3.line); + } + + var testAllocations = response.allocations.filter(isTestAllocation); + ok(testAllocations.length >= 3, + "Should find our 3 test allocations (plus some allocations for the error " + + "objects used to get line numbers)"); + + // For each of the test case's allocations, ensure that the parent frame + // indices are correct. Also test that we did get an allocation at each + // line we expected (rather than a bunch on the first line and none on the + // others, etc). + + var expectedLines = new Set([alloc1.line, alloc2.line, alloc3.line]); + + for (var alloc of testAllocations) { + var innerFrame = response.frames[alloc]; + ok(innerFrame, "Should get the inner frame"); + is(innerFrame.functionDisplayName, "inner"); + expectedLines.delete(innerFrame.line); + + var middleFrame = response.frames[innerFrame.parent]; + ok(middleFrame, "Should get the middle frame"); + is(middleFrame.functionDisplayName, "middle"); + + var outerFrame = response.frames[middleFrame.parent]; + ok(outerFrame, "Should get the outer frame"); + is(outerFrame.functionDisplayName, "outer"); + + // Not going to test the rest of the frames because they are Task.jsm + // and promise frames and it gets gross. Plus, I wouldn't want this test + // to start failing if they changed their implementations in a way that + // added or removed stack frames here. + } + + is(expectedLines.size, 0, + "Should have found all the expected lines"); + + yield memory.detach(); + destroyServerAndFinish(client); + }); +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_memory_allocations_02.html b/devtools/server/tests/mochitest/test_memory_allocations_02.html new file mode 100644 index 000000000..0133a27b0 --- /dev/null +++ b/devtools/server/tests/mochitest/test_memory_allocations_02.html @@ -0,0 +1,76 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1132764 - Test controlling the maximum allocations log length over the RDP. +--> +<head> + <meta charset="utf-8"> + <title>Memory monitoring actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="memory-helpers.js" type="application/javascript;version=1.8"></script> +<script> +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + Task.spawn(function* () { + var { memory, client } = yield startServerAndGetSelectedTabMemory(); + yield memory.attach(); + + var allocs = []; + var eventsFired = 0; + var intervalId = null; + function onAlloc () { + eventsFired++; + } + function startAllocating () { + intervalId = setInterval(() => { + for (var i = 100000; --i;) { + allocs.push(new Object()); + } + }, 1); + } + function stopAllocating () { + clearInterval(intervalId); + } + + memory.on("allocations", onAlloc); + + yield memory.startRecordingAllocations({ + drainAllocationsTimeout: 10 + }); + + yield waitUntil(() => eventsFired > 5); + ok(eventsFired > 5, "Some allocation events fired without allocating much via auto drain"); + yield memory.stopRecordingAllocations(); + + // Set a really high auto drain timer so we can test if + // it fires on GC + eventsFired = 0; + var startTime = performance.now(); + var drainTimer = 1000000; + yield memory.startRecordingAllocations({ + drainAllocationsTimeout: drainTimer + }); + + startAllocating(); + yield waitUntil(() => { + Cu.forceGC(); + return eventsFired > 1; + }); + stopAllocating(); + ok(performance.now() - drainTimer < startTime, "Allocation events fired on GC before timer"); + yield memory.stopRecordingAllocations(); + + memory.off("allocations", onAlloc); + yield memory.detach(); + destroyServerAndFinish(client); + }); +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_memory_allocations_03.html b/devtools/server/tests/mochitest/test_memory_allocations_03.html new file mode 100644 index 000000000..b7d18d7ed --- /dev/null +++ b/devtools/server/tests/mochitest/test_memory_allocations_03.html @@ -0,0 +1,78 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1067491 - Test that frames keep the same index while we are recording. +--> +<head> + <meta charset="utf-8"> + <title>Memory monitoring actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="memory-helpers.js" type="application/javascript;version=1.8"></script> +<script> +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + Task.spawn(function* () { + var { memory, client } = yield startServerAndGetSelectedTabMemory(); + yield memory.attach(); + + yield memory.startRecordingAllocations(); + + // Allocate twice with the exact same stack (hence setTimeout rather than + // allocating directly in the generator), but with getAllocations() calls in + // between. + + var allocs = []; + function allocator() { + allocs.push({}); + } + + setTimeout(allocator, 1); + yield waitForTime(2); + var first = yield memory.getAllocations(); + + setTimeout(allocator, 1); + yield waitForTime(2); + var second = yield memory.getAllocations(); + + yield memory.stopRecordingAllocations(); + + // Assert that each frame in the first response has the same index in the + // second response. This isn't commutative, so we don't check that all + // of the second response's frames are the same in the first response, + // because there might be new allocations that happen after the first query + // but before the second. + + function assertSameFrame(a, b) { + info("Checking frames at index " + i + ":"); + info(" First frame = " + JSON.stringify(a, null, 4)); + info(" Second frame = " + JSON.stringify(b, null, 4)); + + is(!!a, !!b); + if (!a || !b) { + return; + } + + is(a.source, b.source); + is(a.line, b.line); + is(a.column, b.column); + is(a.functionDisplayName, b.functionDisplayName); + is(a.parent, b.parent); + } + + for (var i = 0; i < first.frames.length; i++) { + assertSameFrame(first.frames[i], second.frames[i]); + } + + yield memory.detach(); + destroyServerAndFinish(client); + }); +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_memory_allocations_04.html b/devtools/server/tests/mochitest/test_memory_allocations_04.html new file mode 100644 index 000000000..5568736d3 --- /dev/null +++ b/devtools/server/tests/mochitest/test_memory_allocations_04.html @@ -0,0 +1,60 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1068171 - Test controlling the memory actor's allocation sampling probability. +--> +<head> + <meta charset="utf-8"> + <title>Memory monitoring actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="memory-helpers.js" type="application/javascript;version=1.8"></script> +<script> +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + Task.spawn(function* () { + var { memory, client } = yield startServerAndGetSelectedTabMemory(); + yield memory.attach(); + + var allocs = []; + function allocator() { + for (var i = 0; i < 100; i++) { + allocs.push({}); + } + } + + var testProbability = Task.async(function* (p, expected) { + info("probability = " + p); + yield memory.startRecordingAllocations({ + probability: p + }); + allocator(); + var response = yield memory.getAllocations(); + yield memory.stopRecordingAllocations(); + return response.allocations.length; + }); + + is((yield testProbability(0.0)), 0, + "With probability = 0.0, we shouldn't get any allocations."); + + ok((yield testProbability(1.0)) >= 100, + "With probability = 1.0, we should get all 100 allocations (plus " + + "whatever allocations the actor and SpiderMonkey make)."); + + // We don't test any other probabilities because the test would be + // non-deterministic. We don't have a way to control the PRNG like we do in + // jit-tests + // (js/src/jit-test/tests/debug/Memory-allocationsSamplingProbability-*.js). + + yield memory.detach(); + destroyServerAndFinish(client); + }); +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_memory_allocations_05.html b/devtools/server/tests/mochitest/test_memory_allocations_05.html new file mode 100644 index 000000000..0eeb7bd16 --- /dev/null +++ b/devtools/server/tests/mochitest/test_memory_allocations_05.html @@ -0,0 +1,87 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1068144 - Test getting the timestamps for allocations. +--> +<head> + <meta charset="utf-8"> + <title>Memory monitoring actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="memory-helpers.js" type="application/javascript;version=1.8"></script> +<script> +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + Task.spawn(function* () { + var { memory, client } = yield startServerAndGetSelectedTabMemory(); + yield memory.attach(); + + var allocs = []; + function allocator() { + allocs.push(new Object); + } + + // Using setTimeout results in wildly varying delays that make it hard to + // test our timestamps and results in intermittent failures. Instead, we + // actually spin an empty loop for a whole millisecond. + function actuallyWaitOneWholeMillisecond() { + var start = window.performance.now(); + while (window.performance.now() - start < 1.000) ; + } + + yield memory.startRecordingAllocations(); + + allocator(); + actuallyWaitOneWholeMillisecond(); + allocator(); + actuallyWaitOneWholeMillisecond(); + allocator(); + + var response = yield memory.getAllocations(); + yield memory.stopRecordingAllocations(); + + ok(response.allocationsTimestamps, "The response should have timestamps."); + is(response.allocationsTimestamps.length, response.allocations.length, + "There should be a timestamp for every allocation."); + + var allocatorIndices = response.allocations + .map(function (a, idx) { + var frame = response.frames[a]; + if (frame && frame.functionDisplayName === "allocator") { + return idx; + } + }) + .filter(function (idx) { + return idx !== undefined; + }); + + is(allocatorIndices.length, 3, "Should have our 3 allocations from the `allocator` timeouts."); + + var lastTimestamp; + for (var i = 0; i < 3; i++) { + var timestamp = response.allocationsTimestamps[allocatorIndices[i]]; + info("timestamp", timestamp); + ok(timestamp, "We should have a timestamp for the `allocator` allocation."); + + if (lastTimestamp) { + var delta = timestamp - lastTimestamp; + info("delta since last timestamp", delta); + ok(delta >= 1 /* ms */, + "The timestamp should be about 1 ms after the last timestamp."); + } + + lastTimestamp = timestamp; + } + + yield memory.detach(); + destroyServerAndFinish(client); + }); +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_memory_allocations_06.html b/devtools/server/tests/mochitest/test_memory_allocations_06.html new file mode 100644 index 000000000..56a9f8041 --- /dev/null +++ b/devtools/server/tests/mochitest/test_memory_allocations_06.html @@ -0,0 +1,49 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1132764 - Test controlling the maximum allocations log length over the RDP. +--> +<head> + <meta charset="utf-8"> + <title>Memory monitoring actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="memory-helpers.js" type="application/javascript;version=1.8"></script> +<script> +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + Task.spawn(function* () { + var { memory, client } = yield startServerAndGetSelectedTabMemory(); + yield memory.attach(); + + var allocs = []; + function allocator() { + allocs.push(new Object); + } + + yield memory.startRecordingAllocations({ + maxLogLength: 1 + }); + + allocator(); + allocator(); + allocator(); + + var response = yield memory.getAllocations(); + yield memory.stopRecordingAllocations(); + + is(response.allocations.length, 1, + "There should only be one entry in the allocations log."); + + yield memory.detach(); + destroyServerAndFinish(client); + }); +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_memory_allocations_07.html b/devtools/server/tests/mochitest/test_memory_allocations_07.html new file mode 100644 index 000000000..c26c2d8ec --- /dev/null +++ b/devtools/server/tests/mochitest/test_memory_allocations_07.html @@ -0,0 +1,55 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1192335 - Test getting the byte sizes for allocations. +--> +<head> + <meta charset="utf-8"> + <title>Memory monitoring actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="memory-helpers.js" type="application/javascript;version=1.8"></script> +<script> +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + Task.spawn(function* () { + var { memory, client } = yield startServerAndGetSelectedTabMemory(); + yield memory.attach(); + + var allocs = []; + function allocator() { + allocs.push(new Object); + } + + yield memory.startRecordingAllocations(); + + allocator(); + allocator(); + allocator(); + + var response = yield memory.getAllocations(); + yield memory.stopRecordingAllocations(); + + ok(response.allocationSizes, "The response should have bytesizes."); + is(response.allocationSizes.length, response.allocations.length, + "There should be a bytesize for every allocation."); + ok(response.allocationSizes.length >= 3, + "There are atleast 3 allocations."); + ok(response.allocationSizes.every(isPositiveNumber), "every bytesize is a positive number"); + + yield memory.detach(); + destroyServerAndFinish(client); + }); +}; + +function isPositiveNumber (n) { + return typeof n === "number" && n > 0; +} +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_memory_attach_01.html b/devtools/server/tests/mochitest/test_memory_attach_01.html new file mode 100644 index 000000000..5b0b3f75e --- /dev/null +++ b/devtools/server/tests/mochitest/test_memory_attach_01.html @@ -0,0 +1,31 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 960671 - Test attaching and detaching from a memory actor. +--> +<head> + <meta charset="utf-8"> + <title>Memory monitoring actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="memory-helpers.js" type="application/javascript;version=1.8"></script> +<script> +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + Task.spawn(function* () { + var { memory, client } = yield startServerAndGetSelectedTabMemory(); + yield memory.attach(); + ok(true, "Shouldn't have gotten an error attaching."); + yield memory.detach(); + ok(true, "Shouldn't have gotten an error detaching."); + destroyServerAndFinish(client); + }); +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_memory_attach_02.html b/devtools/server/tests/mochitest/test_memory_attach_02.html new file mode 100644 index 000000000..76269a6df --- /dev/null +++ b/devtools/server/tests/mochitest/test_memory_attach_02.html @@ -0,0 +1,49 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 960671 - Test attaching and detaching while in the wrong state. +--> +<head> + <meta charset="utf-8"> + <title>Memory monitoring actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="memory-helpers.js" type="application/javascript;version=1.8"></script> +<script> +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + Task.spawn(function* () { + var { memory, client } = yield startServerAndGetSelectedTabMemory(); + + var e = null; + try { + yield memory.detach(); + } + catch (ee) { + e = ee; + } + ok(e, "Should have hit the wrongState error"); + + yield memory.attach(); + + e = null; + try { + yield memory.attach(); + } + catch (ee) { + e = ee; + } + ok(e, "Should have hit the wrongState error"); + + yield memory.detach(); + destroyServerAndFinish(client); + }); +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_memory_census.html b/devtools/server/tests/mochitest/test_memory_census.html new file mode 100644 index 000000000..f24050337 --- /dev/null +++ b/devtools/server/tests/mochitest/test_memory_census.html @@ -0,0 +1,33 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1067491 - Test taking a census over the RDP. +--> +<head> + <meta charset="utf-8"> + <title>Memory monitoring actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="memory-helpers.js" type="application/javascript;version=1.8"></script> +<script> +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + Task.spawn(function* () { + var { memory, client } = yield startServerAndGetSelectedTabMemory(); + yield memory.attach(); + + var census = yield memory.takeCensus(); + is(typeof census, "object"); + + yield memory.detach(); + destroyServerAndFinish(client); + }); +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_memory_gc_01.html b/devtools/server/tests/mochitest/test_memory_gc_01.html new file mode 100644 index 000000000..97cb754f0 --- /dev/null +++ b/devtools/server/tests/mochitest/test_memory_gc_01.html @@ -0,0 +1,46 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1067491 - Test forcing a gc. +--> +<head> + <meta charset="utf-8"> + <title>Memory monitoring actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="memory-helpers.js" type="application/javascript;version=1.8"></script> +<script> +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + Task.spawn(function* () { + var { memory, client } = yield startServerAndGetSelectedTabMemory(); + + do { + var objects = []; + for (var i = 0; i < 1000; i++) { + var o = {}; + o[Math.random()] = 1; + objects.push(o); + } + + objects = null; + + var { total: beforeGC } = yield memory.measure(); + + yield memory.forceGarbageCollection(); + var { total: afterGC } = yield memory.measure(); + } while(beforeGC < afterGC); + + ok(true, "The amount of memory after GC should eventually decrease"); + + destroyServerAndFinish(client); + }); +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_memory_gc_events.html b/devtools/server/tests/mochitest/test_memory_gc_events.html new file mode 100644 index 000000000..2297481d4 --- /dev/null +++ b/devtools/server/tests/mochitest/test_memory_gc_events.html @@ -0,0 +1,42 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1137527 - Test receiving GC events from the memory actor. +--> +<head> + <meta charset="utf-8"> + <title>Memory monitoring actor test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script src="memory-helpers.js" type="application/javascript;version=1.8"></script> +<script> +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + var event = require("sdk/event/core"); + + Task.spawn(function* () { + var { memory, client } = yield startServerAndGetSelectedTabMemory(); + yield memory.attach(); + + var gotGcEvent = new Promise(resolve => { + event.on(memory, "garbage-collection", gcData => { + ok(gcData, "Got GC data"); + resolve(); + }); + }); + + memory.forceGarbageCollection(); + yield gotGcEvent; + + yield memory.detach(); + destroyServerAndFinish(client); + }); +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_preference.html b/devtools/server/tests/mochitest/test_preference.html new file mode 100644 index 000000000..54903f455 --- /dev/null +++ b/devtools/server/tests/mochitest/test_preference.html @@ -0,0 +1,115 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 943251 - Allow accessing about:config from WebIDE +--> +<head> + <meta charset="utf-8"> + <title>Test Preference Actor</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +function runTests() { + var Cu = Components.utils; + var Cc = Components.classes; + var Ci = Components.interfaces; + + var {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); + var {DebuggerClient} = require("devtools/shared/client/main"); + var {DebuggerServer} = require("devtools/server/main"); + var Services = require("Services"); + + SimpleTest.waitForExplicitFinish(); + + var {getPreferenceFront} = require("devtools/shared/fronts/preference"); + + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + + var client = new DebuggerClient(DebuggerServer.connectPipe()); + client.connect().then(function onConnect() { + client.listTabs(function onListTabs(aResponse) { + var p = getPreferenceFront(client, aResponse); + + var prefs = {}; + + var localPref = { + boolPref: true, + intPref: 0x1234, + charPref: "Hello World", + }; + + + function checkValues() { + is(prefs.boolPref, localPref.boolPref, "read/write bool pref"); + is(prefs.intPref, localPref.intPref, "read/write int pref"); + is(prefs.charPref, localPref.charPref, "read/write string pref"); + + ["test.all.bool", "test.all.int", "test.all.string"].forEach(function(key) { + var expectedValue; + switch(Services.prefs.getPrefType(key)) { + case Ci.nsIPrefBranch.PREF_STRING: + expectedValue = Services.prefs.getCharPref(key); + break; + case Ci.nsIPrefBranch.PREF_INT: + expectedValue = Services.prefs.getIntPref(key); + break; + case Ci.nsIPrefBranch.PREF_BOOL: + expectedValue = Services.prefs.getBoolPref(key); + break; + default: + ok(false, "unexpected pref type (" + key + ")"); + break; + } + + is(prefs.allPrefs[key].value, expectedValue, "valid preference value (" + key + ")"); + is(prefs.allPrefs[key].hasUserValue, Services.prefs.prefHasUserValue(key), "valid hasUserValue (" + key + ")"); + }); + + ["test.bool", "test.int", "test.string"].forEach(function(key) { + ok(!prefs.allPrefs.hasOwnProperty(key), "expect no pref (" + key + ")"); + is(Services.prefs.getPrefType(key), Ci.nsIPrefBranch.PREF_INVALID, "pref (" + key + ") is clear"); + }); + + client.close().then(() => { + DebuggerServer.destroy(); + SimpleTest.finish() + }); + } + + + p.getAllPrefs().then((json) => prefs["allPrefs"] = json) + .then(() => p.setBoolPref("test.bool", localPref.boolPref)) + .then(() => p.setIntPref("test.int", localPref.intPref)) + .then(() => p.setCharPref("test.string", localPref.charPref)) + .then(() => p.getBoolPref("test.bool")).then((value) => prefs["boolPref"] = value) + .then(() => p.getIntPref("test.int")).then((value) => prefs["intPref"] = value) + .then(() => p.getCharPref("test.string")).then((value) => prefs["charPref"] = value) + .then(() => p.clearUserPref("test.bool")) + .then(() => p.clearUserPref("test.int")) + .then(() => p.clearUserPref("test.string")) + .then(checkValues); + + }); + }); + +} + +window.onload = function () { + SpecialPowers.pushPrefEnv({ + "set": [ + ["devtools.debugger.forbid-certified-apps", false], + ["test.all.bool", true], + ["test.all.int", 0x4321], + ["test.all.string", "allizom"], + ] + }, runTests); +} +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_settings.html b/devtools/server/tests/mochitest/test_settings.html new file mode 100644 index 000000000..5665b46b3 --- /dev/null +++ b/devtools/server/tests/mochitest/test_settings.html @@ -0,0 +1,130 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1022797 - Settings support from WebIDE +--> +<head> + <meta charset="utf-8"> + <title>Test Settings Actor</title> + <script type="text/javascript" src="chrome://mochikit/content/MochiKit/MochiKit.js"></script> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +function runTests() { + var Cu = Components.utils; + var Cc = Components.classes; + var Ci = Components.interfaces; + + var {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); + var {DebuggerClient} = require("devtools/shared/client/main"); + var {DebuggerServer} = require("devtools/server/main"); + + if (SpecialPowers.isMainProcess()) { + Cu.import("resource://gre/modules/SettingsRequestManager.jsm"); + } + + SimpleTest.waitForExplicitFinish(); + + var {getSettingsFront} = require("devtools/shared/fronts/settings"); + var {_setDefaultSettings} = require("devtools/server/actors/settings"); + + DebuggerServer.init(function () { return true; }); + DebuggerServer.addBrowserActors(); + + var client = new DebuggerClient(DebuggerServer.connectPipe()); + client.connect().then(function onConnect() { + client.listTabs(function onListTabs(aResponse) { + var s = getSettingsFront(client, aResponse); + + var settings = {}; + var resetSettings = {}; + var fakeSettings = { + "wifi.enabled": true, + "audio.volume.alarm": 15, + "app.reportCrashes": "ask", + "app.someObject": { active: true } + }; + var localSetting = { + "wifi.enabled": false, + "audio.volume.alarm": 0, + "app.reportCrashes": "none", + "app.someObject": {} + }; + + function checkValues() { + is(settings.allSettings["wifi.enabled"].hasUserValue, false, "original unchanged bool setting"); + is(settings.allSettings["audio.volume.alarm"].hasUserValue, false, "original unchanged int setting"); + is(settings.allSettings["app.reportCrashes"].hasUserValue, false, "original unchanged string setting"); + is(settings.allSettings["app.someObject"].hasUserValue, false, "original unchanged object setting"); + + is(settings.allSettings["wifi.enabled"].value, fakeSettings["wifi.enabled"], "original read/write bool setting"); + is(settings.allSettings["audio.volume.alarm"].value, fakeSettings["audio.volume.alarm"], "original read/write int setting"); + is(settings.allSettings["app.reportCrashes"].value, fakeSettings["app.reportCrashes"], "original read/write string setting"); + is(JSON.stringify(settings.allSettings["app.someObject"].value), JSON.stringify(fakeSettings["app.someObject"]), "original read/write object setting"); + + is(settings.allUpdatedSettings["wifi.enabled"].hasUserValue, true, "updated user-changed bool setting"); + is(settings.allUpdatedSettings["audio.volume.alarm"].hasUserValue, true, "updated user-changed int setting"); + is(settings.allUpdatedSettings["app.reportCrashes"].hasUserValue, true, "updated user-changed string setting"); + is(settings.allUpdatedSettings["app.someObject"].hasUserValue, true, "updated user-changed object setting"); + + is(settings["wifi.enabled"], localSetting["wifi.enabled"], "updated bool setting"); + is(settings["audio.volume.alarm"], localSetting["audio.volume.alarm"], "updated int setting"); + is(settings["app.reportCrashes"], localSetting["app.reportCrashes"], "updated string setting"); + is(JSON.stringify(settings["app.someObject"]), JSON.stringify(localSetting["app.someObject"]), "updated object as string setting"); + + is(resetSettings["wifi.enabled"], fakeSettings["wifi.enabled"], "reset to original bool setting"); + is(resetSettings["audio.volume.alarm"], fakeSettings["audio.volume.alarm"], "reset to original int setting"); + is(resetSettings["app.reportCrashes"], fakeSettings["app.reportCrashes"], "reset to original string setting"); + is(JSON.stringify(resetSettings["app.someObject"]), JSON.stringify(fakeSettings["app.someObject"]), "reset to original object setting"); + + client.close().then(() => { + DebuggerServer.destroy(); + SimpleTest.finish(); + }); + } + + // settings.json doesn't exist outside of b2g so we will fake it. + _setDefaultSettings(fakeSettings); + s.setSetting("wifi.enabled", fakeSettings["wifi.enabled"]) + .then(() => s.setSetting("audio.volume.alarm", fakeSettings["audio.volume.alarm"])) + .then(() => s.setSetting("app.reportCrashes", fakeSettings["app.reportCrashes"])) + .then(() => s.setSetting("app.someObject", fakeSettings["app.someObject"])) + .then(() => s.getAllSettings().then(json => settings.allSettings = json)) + .then(() => s.setSetting("wifi.enabled", localSetting["wifi.enabled"])) + .then(() => s.setSetting("audio.volume.alarm", localSetting["audio.volume.alarm"])) + .then(() => s.setSetting("app.reportCrashes", localSetting["app.reportCrashes"])) + .then(() => s.setSetting("app.someObject", localSetting["app.someObject"])) + .then(() => s.getAllSettings().then(json => settings.allUpdatedSettings = json)) + .then(() => s.getSetting("wifi.enabled")).then(value => settings["wifi.enabled"] = value) + .then(() => s.getSetting("audio.volume.alarm")).then(value => settings["audio.volume.alarm"] = value) + .then(() => s.getSetting("app.reportCrashes")).then(value => settings["app.reportCrashes"] = value) + .then(() => s.getSetting("app.someObject")).then(value => settings["app.someObject"] = value) + .then(() => s.clearUserSetting("wifi.enabled")).then(() => { + s.getSetting("wifi.enabled").then(value => resetSettings["wifi.enabled"] = value); + }) + .then(() => s.clearUserSetting("audio.volume.alarm")).then(() => { + s.getSetting("audio.volume.alarm").then(value => resetSettings["audio.volume.alarm"] = value); + }) + .then(() => s.clearUserSetting("app.reportCrashes")).then(() => { + s.getSetting("app.reportCrashes").then(value => resetSettings["app.reportCrashes"] = value); + }) + .then(() => s.clearUserSetting("app.someObject")).then(() => { + s.getSetting("app.someObject").then(value => { + resetSettings["app.someObject"] = value + }).then(checkValues); + }); + }); + }); +} + +window.onload = function () { + runTests(); +} +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_setupInParentChild.html b/devtools/server/tests/mochitest/test_setupInParentChild.html new file mode 100644 index 000000000..fc94ca96a --- /dev/null +++ b/devtools/server/tests/mochitest/test_setupInParentChild.html @@ -0,0 +1,110 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 1181100 - Test DebuggerServerConnection.setupInParent and DebuggerServer.setupInChild +--> +<head> + <meta charset="utf-8"> + <title>Mozilla Bug</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script type="application/javascript;version=1.8"> + +let Cu = Components.utils; +let Cc = Components.classes; +let Ci = Components.interfaces; + +let {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); +let {DebuggerClient} = require("devtools/shared/client/main"); +let {DebuggerServer} = require("devtools/server/main"); +let Services = require("Services"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + SpecialPowers.pushPrefEnv({ + "set": [ + // Always log packets when running tests. + ["devtools.debugger.log", true], + ["dom.mozBrowserFramesEnabled", true] + ] + }, runTests); +} + +function runTests() { + // Create a minimal iframe with a message manager + let iframe = document.createElement("iframe"); + iframe.mozbrowser = true; + document.body.appendChild(iframe); + + let mm = iframe.frameLoader.messageManager; + + // Instantiate a minimal server + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + } + if (!DebuggerServer.createRootActor) { + DebuggerServer.addBrowserActors(); + } + + // Fake a connection to an iframe + let transport = DebuggerServer.connectPipe(); + let conn = transport._serverConnection; + let client = new DebuggerClient(transport); + + // Wait for a response from setupInChild + const ppmm = Cc["@mozilla.org/parentprocessmessagemanager;1"] + .getService(Ci.nsIMessageListenerManager); + let onChild = msg => { + ppmm.removeMessageListener("test:setupChild", onChild); + let args = msg.json; + + is(args[0], 1, "Got first numeric argument"); + is(args[1], "two", "Got second string argument"); + is(args[2].three, true, "Got last JSON argument"); + + // Ask the child to call setupInParent + DebuggerServer.setupInChild({ + module: "chrome://mochitests/content/chrome/devtools/server/tests/mochitest/setup-in-child.js", + setupChild: "callParent" + }); + }; + ppmm.addMessageListener("test:setupChild", onChild); + + // Wait also for a reponse from setupInParent called from setup-in-child.js + let onParent = (_, topic, args) => { + Services.obs.removeObserver(onParent, "test:setupParent", false); + args = JSON.parse(args); + + is(args[0], true, "Got `mm` argument, a message manager"); + ok(args[1].match(/server\d+.conn\d+.child\d+/), "Got `prefix` argument"); + + cleanup(); + }; + Services.obs.addObserver(onParent, "test:setupParent", false); + + // Instanciate e10s machinery and call setupInChild + DebuggerServer.connectToChild(conn, iframe).then(actor => { + DebuggerServer.setupInChild({ + module: "chrome://mochitests/content/chrome/devtools/server/tests/mochitest/setup-in-child.js", + setupChild: "setupChild", + args: [1, "two", {three: true}] + }); + }); + + function cleanup() { + client.close().then(function () { + DebuggerServer.destroy(); + iframe.remove(); + SimpleTest.finish() + }); + } + +} +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_styles-applied.html b/devtools/server/tests/mochitest/test_styles-applied.html new file mode 100644 index 000000000..d9fb6ec7f --- /dev/null +++ b/devtools/server/tests/mochitest/test_styles-applied.html @@ -0,0 +1,145 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/server/actors/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gWalker = null; +var gStyles = null; +var gClient = null; + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + return inspector.getPageStyle(); + }).then(styles => { + gStyles = styles; + }).then(runNextTest)); + }); +}); + +addTest(function inheritedUserStyles() { + promiseDone(gWalker.querySelector(gWalker.rootNode, "#test-node").then(node => { + return gStyles.getApplied(node, { inherited: true, filter: "user" }); + }).then(applied => { + ok(!applied[0].inherited, "Entry 0 should be uninherited"); + is(applied[0].rule.type, 100, "Entry 0 should be an element style"); + ok(!!applied[0].rule.href, "Element styles should have a URL"); + is(applied[0].rule.cssText, "", "Entry 0 should be an empty style"); + + is(applied[1].inherited.id, "uninheritable-rule-inheritable-style", + "Entry 1 should be inherited from the parent"); + is(applied[1].rule.type, 100, "Entry 1 should be an element style"); + is(applied[1].rule.cssText, "color: red;", "Entry 1 should have the expected cssText"); + + is(applied[2].inherited.id, "inheritable-rule-inheritable-style", + "Entry 2 should be inherited from the parent's parent"); + is(applied[2].rule.type, 100, "Entry 2 should be an element style"); + is(applied[2].rule.cssText, "color: blue;", "Entry 2 should have the expected cssText"); + + is(applied[3].inherited.id, "inheritable-rule-inheritable-style", + "Entry 3 should be inherited from the parent's parent"); + is(applied[3].rule.type, 1, "Entry 3 should be a rule style"); + is(applied[3].rule.cssText, "font-size: 15px;", "Entry 3 should have the expected cssText"); + ok(!applied[3].matchedSelectors, "Shouldn't get matchedSelectors with this request."); + + is(applied[4].inherited.id, "inheritable-rule-uninheritable-style", + "Entry 4 should be inherited from the parent's parent"); + is(applied[4].rule.type, 1, "Entry 4 should be an rule style"); + is(applied[4].rule.cssText, "font-size: 15px;", "Entry 4 should have the expected cssText"); + ok(!applied[4].matchedSelectors, "Shouldn't get matchedSelectors with this request."); + + is(applied.length, 5, "Should have 5 rules."); + }).then(runNextTest)); +}); + +addTest(function inheritedSystemStyles() { + promiseDone(gWalker.querySelector(gWalker.rootNode, "#test-node").then(node => { + return gStyles.getApplied(node, { inherited: true, filter: "ua" }); + }).then(applied => { + // If our system stylesheets are prone to churn, this might be a fragile + // test. If you're here because of that I apologize, file a bug + // and we can find a different way to test. + + ok(!applied[1].inherited, "Entry 1 should not be inherited"); + ok(!applied[1].rule.parentStyleSheet.system, "Entry 1 should be a system style"); + is(applied[1].rule.type, 1, "Entry 1 should be a rule style"); + + is(applied.length, 12, "Should have 12 rules."); + }).then(runNextTest)); +}); + +addTest(function noInheritedStyles() { + promiseDone(gWalker.querySelector(gWalker.rootNode, "#test-node").then(node => { + return gStyles.getApplied(node, { inherited: false, filter: "user" }); + }).then(applied => { + ok(!applied[0].inherited, "Entry 0 should be uninherited"); + is(applied[0].rule.type, 100, "Entry 0 should be an element style"); + is(applied[0].rule.cssText, "", "Entry 0 should be an empty style"); + is(applied.length, 1, "Should have 1 rule."); + }).then(runNextTest)); +}); + +addTest(function matchedSelectors() { + promiseDone(gWalker.querySelector(gWalker.rootNode, "#test-node").then(node => { + return gStyles.getApplied(node, { + inherited: true, filter: "user", matchedSelectors: true + }); + }).then(applied => { + is(applied[3].matchedSelectors[0], ".inheritable-rule", "Entry 3 should have a matched selector"); + is(applied[4].matchedSelectors[0], ".inheritable-rule", "Entry 4 should have a matched selector"); + }).then(runNextTest)); +}); + +addTest(function testMediaQuery() { + promiseDone(gWalker.querySelector(gWalker.rootNode, "#mediaqueried").then(node => { + return gStyles.getApplied(node, { + inherited: false, filter: "user", matchedSelectors: true + }); + }).then(applied => { + is(applied[1].rule.type, 1, "Entry 1 is a rule style"); + is(applied[1].rule.parentRule.type, 4, "Entry 1's parent rule is a media rule"); + is(applied[1].rule.media[0], "screen", "Entry 1's rule has the expected medium"); + }).then(runNextTest)); +}); + +addTest(function cleanup() { + delete gStyles; + delete gWalker; + delete gClient; + runNextTest(); +}); + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-styles-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_styles-computed.html b/devtools/server/tests/mochitest/test_styles-computed.html new file mode 100644 index 000000000..c70adc8eb --- /dev/null +++ b/devtools/server/tests/mochitest/test_styles-computed.html @@ -0,0 +1,139 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/server/actors/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gWalker = null; +var gStyles = null; +var gClient = null; + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + return inspector.getPageStyle(); + }).then(styles => { + gStyles = styles; + }).then(runNextTest)); + }); +}); + +addTest(function testComputed() { + let localNode = gInspectee.querySelector("#computed-test-node"); + let elementStyle = null; + promiseDone(gWalker.querySelector(gWalker.rootNode, "#computed-test-node").then(node => { + return gStyles.getComputed(node, {}); + }).then(computed => { + // Test a smattering of properties that include some system-defined + // props, some props that were defined in this node's stylesheet, + // and some default props. + is(computed["white-space"].value, "normal", "Default value should appear"); + is(computed["display"].value, "block", "System stylesheet item should appear"); + is(computed["cursor"].value, "crosshair", "Included stylesheet rule should appear"); + is(computed["color"].value, "rgb(255, 0, 0)", "Inherited style attribute should appear"); + is(computed["font-size"].value, "15px", "Inherited inline rule should appear"); + + // We didn't request markMatched, so these shouldn't be set + ok(!computed["cursor"].matched, "Didn't ask for matched, shouldn't get it"); + ok(!computed["color"].matched, "Didn't ask for matched, shouldn't get it"); + ok(!computed["font-size"].matched, "Didn't ask for matched, shouldn't get it"); + }).then(runNextTest)); +}); + +addTest(function testComputedUserMatched() { + let localNode = gInspectee.querySelector("#computed-test-node"); + let elementStyle = null; + promiseDone(gWalker.querySelector(gWalker.rootNode, "#computed-test-node").then(node => { + return gStyles.getComputed(node, { filter: "user", markMatched: true }); + }).then(computed => { + ok(!computed["white-space"].matched, "Default style shouldn't match"); + ok(!computed["display"].matched, "Only user styles should match"); + ok(computed["cursor"].matched, "Asked for matched, should get it"); + ok(computed["color"].matched, "Asked for matched, should get it"); + ok(computed["font-size"].matched, "Asked for matched, should get it"); + }).then(runNextTest)); +}); + +addTest(function testComputedSystemMatched() { + let localNode = gInspectee.querySelector("#computed-test-node"); + let elementStyle = null; + promiseDone(gWalker.querySelector(gWalker.rootNode, "#computed-test-node").then(node => { + return gStyles.getComputed(node, { filter: "ua", markMatched: true }); + }).then(computed => { + ok(!computed["white-space"].matched, "Default style shouldn't match"); + ok(computed["display"].matched, "System stylesheets should match"); + ok(computed["cursor"].matched, "Asked for matched, should get it"); + ok(computed["color"].matched, "Asked for matched, should get it"); + ok(computed["font-size"].matched, "Asked for matched, should get it"); + }).then(runNextTest)); +}); + +addTest(function testComputedUserOnlyMatched() { + let localNode = gInspectee.querySelector("#computed-test-node"); + let elementStyle = null; + promiseDone(gWalker.querySelector(gWalker.rootNode, "#computed-test-node").then(node => { + return gStyles.getComputed(node, { filter: "user", onlyMatched: true }); + }).then(computed => { + ok(!("white-space" in computed), "Default style shouldn't exist"); + ok(!("display" in computed), "System stylesheets shouldn't exist"); + ok(("cursor" in computed), "User items should exist."); + ok(("color" in computed), "User items should exist."); + ok(("font-size" in computed), "User items should exist."); + }).then(runNextTest)); +}); + +addTest(function testComputedSystemOnlyMatched() { + let localNode = gInspectee.querySelector("#computed-test-node"); + let elementStyle = null; + promiseDone(gWalker.querySelector(gWalker.rootNode, "#computed-test-node").then(node => { + return gStyles.getComputed(node, { filter: "ua", onlyMatched: true }); + }).then(computed => { + ok(!("white-space" in computed), "Default style shouldn't exist"); + ok(("display" in computed), "System stylesheets should exist"); + ok(("cursor" in computed), "User items should exist."); + ok(("color" in computed), "User items should exist."); + ok(("font-size" in computed), "User items should exist."); + }).then(runNextTest)); +}); + +addTest(function cleanup() { + delete gStyles; + delete gWalker; + delete gClient; + runNextTest(); +}); + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-styles-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_styles-layout.html b/devtools/server/tests/mochitest/test_styles-layout.html new file mode 100644 index 000000000..b2134b0c9 --- /dev/null +++ b/devtools/server/tests/mochitest/test_styles-layout.html @@ -0,0 +1,116 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +<title>Test for Bug 1175040 - PageStyleActor.getLayout</title> +<script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> +<link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +<script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> +<script type="application/javascript;version=1.8"> +"use strict"; + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +}; + +let gWalker = null; +let gStyles = null; + +addTest(function() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gWalker = walker; + return inspector.getPageStyle(); + }).then(styles => { + gStyles = styles; + }).then(runNextTest)); + }); +}); + +addTest(function() { + ok(gStyles.getLayout, "The PageStyleActor has a getLayout method"); + runNextTest(); +}); + +addAsyncTest(function*() { + let node = yield gWalker.querySelector(gWalker.rootNode, "#layout-element"); + let layout = yield gStyles.getLayout(node, {}); + + let properties = ["width", "height", + "margin-top", "margin-right", "margin-bottom", + "margin-left", "padding-top", "padding-right", + "padding-bottom", "padding-left", "border-top-width", + "border-right-width", "border-bottom-width", + "border-left-width", "z-index", "box-sizing", "display", + "position"]; + for (let prop of properties) { + ok((prop in layout), "The layout object returned has " + prop); + } + + runNextTest(); +}); + +addAsyncTest(function*() { + let node = yield gWalker.querySelector(gWalker.rootNode, "#layout-element"); + let layout = yield gStyles.getLayout(node, {}); + + let expected = { + "box-sizing": "border-box", + "position": "absolute", + "z-index": "2", + "display": "block", + "width": 50, + "height": 50, + "margin-top": "10px", + "margin-right": "20px", + "margin-bottom": "30px", + "margin-left": "0px" + }; + + for (let name in expected) { + is(layout[name], expected[name], "The " + name + " property is correct"); + } + + runNextTest(); +}); + +addAsyncTest(function*() { + let node = yield gWalker.querySelector(gWalker.rootNode, + "#layout-auto-margin-element"); + + let layout = yield gStyles.getLayout(node, {}); + ok(!("autoMargins" in layout), + "By default, getLayout doesn't return auto margins"); + + layout = yield gStyles.getLayout(node, {autoMargins: true}); + ok(("autoMargins" in layout), + "getLayout does return auto margins when asked to"); + is(layout.autoMargins.left, "auto", "The left margin is auto"); + is(layout.autoMargins.right, "auto", "The right margin is auto"); + ok(!layout.autoMargins.bottom, "The bottom margin is not auto"); + ok(!layout.autoMargins.top, "The top margin is not auto"); + + runNextTest(); +}); + +addTest(function() { + gStyles = gWalker = null; + runNextTest(); +}); + +</script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1175040">Mozilla Bug 1175040</a> +<a id="inspectorContent" target="_blank" href="inspector-styles-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"></div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_styles-matched.html b/devtools/server/tests/mochitest/test_styles-matched.html new file mode 100644 index 000000000..4d24fbe70 --- /dev/null +++ b/devtools/server/tests/mochitest/test_styles-matched.html @@ -0,0 +1,98 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/server/actors/inspector"); +const CssLogic = require("devtools/shared/inspector/css-logic"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gWalker = null; +var gStyles = null; +var gClient = null; + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + return inspector.getPageStyle(); + }).then(styles => { + gStyles = styles; + }).then(runNextTest)); + }); +}); + +addTest(function testMatchedStyles() { + promiseDone(gWalker.querySelector(gWalker.rootNode, "#matched-test-node").then(node => { + return gStyles.getMatchedSelectors(node, "font-size", {}); + }).then(matched => { + is(matched[0].sourceText, "this.style", "First match comes from the element style"); + is(matched[0].selector, "@element.style", "Element style has a special selector"); + is(matched[0].value, "10px", "First match has the expected value"); + is(matched[0].status, CssLogic.STATUS.BEST, "First match is the best match") + is(matched[0].rule.type, 100, "First match is an element style"); + is(matched[0].rule.href, gInspectee.defaultView.location.href, "Node style comes from this document") + + is(matched[1].sourceText, ".inheritable-rule", "Second match comes from a rule"); + is(matched[1].selector, ".inheritable-rule", "Second style has a selector"); + is(matched[1].value, "15px", "Second match has the expected value"); + is(matched[1].status, CssLogic.STATUS.PARENT_MATCH, "Second match is from the parent") + is(matched[1].rule.parentStyleSheet.href, null, "Inline stylesheet shouldn't have an href"); + is(matched[1].rule.parentStyleSheet.nodeHref, gInspectee.defaultView.location.href, "Inline stylesheet's nodeHref should match the current document"); + ok(!matched[1].rule.parentStyleSheet.system, "Inline stylesheet shouldn't be a system stylesheet."); + }).then(runNextTest)); +}); + +addTest(function testSystemStyles() { + let testNode = null; + + promiseDone(gWalker.querySelector(gWalker.rootNode, "#matched-test-node").then(node => { + testNode = node; + return gStyles.getMatchedSelectors(testNode, "display", { filter: "user" }); + }).then(matched => { + is(matched.length, 0, "No user selectors apply to this rule."); + return gStyles.getMatchedSelectors(testNode, "display", { filter: "ua" }); + }).then(matched => { + is(matched[0].selector, "div", "Should match system div selector"); + is(matched[0].value, "block"); + }).then(runNextTest)); +}); + +addTest(function cleanup() { + delete gStyles; + delete gWalker; + delete gClient; + runNextTest(); +}); + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-styles-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_styles-modify.html b/devtools/server/tests/mochitest/test_styles-modify.html new file mode 100644 index 000000000..5a8e20bc3 --- /dev/null +++ b/devtools/server/tests/mochitest/test_styles-modify.html @@ -0,0 +1,116 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id= +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/server/actors/inspector"); +const {isCssPropertyKnown} = require("devtools/server/actors/css-properties"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gWalker = null; +var gStyles = null; +var gClient = null; + +addAsyncTest(function* setup() { + let url = document.getElementById("inspectorContent").href; + let inspector; + + yield new Promise(resolve => { + attachURL(url, function(err, client, tab, doc) { + gInspectee = doc; + gClient = client; + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + inspector = InspectorFront(client, tab); + resolve(); + }); + }); + + gWalker = yield inspector.getWalker(); + gStyles = yield inspector.getPageStyle(); + + runNextTest(); +}); + +addAsyncTest(function* modifyProperties() { + let localNode = gInspectee.querySelector("#inheritable-rule-inheritable-style"); + + let node = yield gWalker.querySelector(gWalker.rootNode, + "#inheritable-rule-inheritable-style"); + + let applied = yield gStyles.getApplied(node, + { inherited: false, filter: "user" }); + + let elementStyle = applied[0].rule; + is(elementStyle.cssText, localNode.style.cssText, "Got expected css text"); + + // Change an existing property... + yield setProperty(elementStyle, 0, "color", "black"); + // Create a new property + yield setProperty(elementStyle, 1, "background-color", "green"); + + // Create a new property and then change it immediately. + yield setProperty(elementStyle, 2, "border", "1px solid black"); + yield setProperty(elementStyle, 2, "border", "2px solid black"); + + is(elementStyle.cssText, + "color: black; background-color: green; border: 2px solid black;", + "Should have expected cssText"); + is(elementStyle.cssText, localNode.style.cssText, + "Local node and style front match."); + + // Remove all the properties + yield removeProperty(elementStyle, 0, "color"); + yield removeProperty(elementStyle, 0, "background-color"); + yield removeProperty(elementStyle, 0, "border"); + + is(elementStyle.cssText, "", "Should have expected cssText"); + is(elementStyle.cssText, localNode.style.cssText, + "Local node and style front match."); + + runNextTest(); +}); + +function* setProperty(rule, index, name, value) { + let changes = rule.startModifyingProperties(isCssPropertyKnown); + changes.setProperty(index, name, value); + yield changes.apply(); +} + +function* removeProperty(rule, index, name) { + let changes = rule.startModifyingProperties(isCssPropertyKnown); + changes.removeProperty(index, name); + yield changes.apply(); +} + +addTest(function cleanup() { + delete gStyles; + delete gWalker; + delete gClient; + runNextTest(); +}); + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a> +<a id="inspectorContent" target="_blank" href="inspector-styles-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_styles-svg.html b/devtools/server/tests/mochitest/test_styles-svg.html new file mode 100644 index 000000000..51a84420c --- /dev/null +++ b/devtools/server/tests/mochitest/test_styles-svg.html @@ -0,0 +1,70 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=921191 +Bug 921191 - allow inspection/editing of SVG elements' CSS properties +--> +<head> + <meta charset="utf-8"> + <title>Test for Bug </title> + + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript;version=1.8" src="inspector-helpers.js"></script> + <script type="application/javascript;version=1.8"> +const inspector = require("devtools/server/actors/inspector"); + +window.onload = function() { + SimpleTest.waitForExplicitFinish(); + runNextTest(); +} + +var gWalker = null; +var gStyles = null; +var gClient = null; + +addTest(function setup() { + let url = document.getElementById("inspectorContent").href; + attachURL(url, function(err, client, tab, doc) { + let {InspectorFront} = require("devtools/shared/fronts/inspector"); + let inspector = InspectorFront(client, tab); + promiseDone(inspector.getWalker().then(walker => { + ok(walker, "getWalker() should return an actor."); + gClient = client; + gWalker = walker; + return inspector.getPageStyle(); + }).then(styles => { + gStyles = styles; + }).then(runNextTest)); + }); +}); + +addTest(function inheritedUserStyles() { + promiseDone(gWalker.querySelector(gWalker.rootNode, "#svgcontent rect").then(node => { + return gStyles.getApplied(node, { inherited: true, filter: "user" }); + }).then(applied => { + is(applied.length, 2, "Should have 2 rules"); + is(applied[1].rule.cssText, "fill: rgb(1, 2, 3);", "cssText is right"); + }).then(runNextTest)); +}); + +addTest(function cleanup() { + delete gStyles; + delete gWalker; + delete gClient; + runNextTest(); +}); + + </script> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=921191">Mozilla Bug 921191</a> +<a id="inspectorContent" target="_blank" href="inspector-styles-data.html">Test Document</a> +<p id="display"></p> +<div id="content" style="display: none"> + +</div> +<pre id="test"> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_unsafeDereference.html b/devtools/server/tests/mochitest/test_unsafeDereference.html new file mode 100644 index 000000000..df44fac51 --- /dev/null +++ b/devtools/server/tests/mochitest/test_unsafeDereference.html @@ -0,0 +1,52 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=837723 + +When we use Debugger.Object.prototype.unsafeDereference to get a non-D.O +reference to a content object in chrome, that reference should be via an +xray wrapper. +--> +<head> + <meta charset="utf-8"> + <title>Mozilla Bug 837723</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<pre id="test"> +<script> + +Components.utils.import("resource://gre/modules/jsdebugger.jsm"); +addDebuggerToGlobal(this); + +window.onload = function () { + SimpleTest.waitForExplicitFinish(); + + var iframe = document.createElement("iframe"); + iframe.src = "http://mochi.test:8888/chrome/devtools/server/tests/mochitest/nonchrome_unsafeDereference.html"; + + iframe.onload = function () { + var dbg = new Debugger; + var contentDO = dbg.addDebuggee(iframe.contentWindow); + var xhrDesc = contentDO.getOwnPropertyDescriptor('xhr'); + + isnot(xhrDesc, undefined, "xhr should be visible as property of content global"); + isnot(xhrDesc.value, undefined, "xhr should have a value"); + + var xhr = xhrDesc.value.unsafeDereference(); + + is(typeof xhr, "object", "we should be able to deference xhr's value's D.O"); + is(xhr.timeout, 1742, "chrome should see the xhr's 'timeout' property"); + is(xhr.expando, undefined, "chrome should not see the xhr's 'expando' property"); + + SimpleTest.finish(); + } + + document.body.appendChild(iframe); +} + +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/mochitest/test_websocket-server.html b/devtools/server/tests/mochitest/test_websocket-server.html new file mode 100644 index 000000000..583d96dd9 --- /dev/null +++ b/devtools/server/tests/mochitest/test_websocket-server.html @@ -0,0 +1,82 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Mozilla Bug</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<script> +window.onload = function() { + const { Constructor: CC, utils: Cu } = Components; + const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); + const { Task } = require("devtools/shared/task"); + const WebSocketServer = require("devtools/server/websocket-server"); + + const ServerSocket = CC("@mozilla.org/network/server-socket;1", + "nsIServerSocket", "init"); + + add_task(function* () { + // Create a TCP server on auto-assigned port + let server = new ServerSocket(-1, true, -1); + ok(server, `Launched WebSocket server on port ${server.port}`); + server.asyncListen({ + onSocketAccepted: Task.async(function* (socket, transport) { + info("Accepted incoming connection"); + let input = transport.openInputStream(0, 0, 0); + let output = transport.openOutputStream(0, 0, 0); + + // Perform the WebSocket handshake + let webSocket = yield WebSocketServer.accept(transport, input, output); + + // Echo the received message back to the sender + webSocket.onmessage = ({ data }) => { + info("Server received message, echoing back"); + webSocket.send(data); + }; + }), + + onStopListening(socket, status) { + info(`Server stopped listening with status: ${status}`); + } + }); + + SimpleTest.registerCleanupFunction(() => { + server.close(); + }); + + // Create client connection + let client = yield new Promise((resolve, reject) => { + let socket = new WebSocket(`ws://localhost:${server.port}`); + socket.onopen = () => resolve(socket); + socket.onerror = reject; + }); + ok(client, `Created WebSocket connection to port ${server.port}`); + + // Create a promise that resolves when the WebSocket closes + let closed = new Promise(resolve => { + client.onclose = resolve; + }); + + // Send a message + let message = "hello there"; + client.send(message); + info("Sent a message to server"); + // Check that it was echoed + let echoedMessage = yield new Promise((resolve, reject) => { + client.onmessage = ({ data }) => resolve(data); + client.onerror = reject; + }); + + is(echoedMessage, message, "Echoed message matches"); + + // Close the connection + client.close(); + yield closed; + }); +} +</script> +</body> +</html> diff --git a/devtools/server/tests/unit/.eslintrc.js b/devtools/server/tests/unit/.eslintrc.js new file mode 100644 index 000000000..012428019 --- /dev/null +++ b/devtools/server/tests/unit/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the common devtools xpcshell eslintrc config. + "extends": "../../../.eslintrc.xpcshell.js" +}; diff --git a/devtools/server/tests/unit/addons/web-extension-upgrade/manifest.json b/devtools/server/tests/unit/addons/web-extension-upgrade/manifest.json new file mode 100644 index 000000000..f70b11efd --- /dev/null +++ b/devtools/server/tests/unit/addons/web-extension-upgrade/manifest.json @@ -0,0 +1,10 @@ +{ + "manifest_version": 2, + "name": "Test Addons Actor Upgrade", + "version": "1.0", + "applications": { + "gecko": { + "id": "test-addons-actor@mozilla.org" + } + } +} diff --git a/devtools/server/tests/unit/addons/web-extension/manifest.json b/devtools/server/tests/unit/addons/web-extension/manifest.json new file mode 100644 index 000000000..d120cf3da --- /dev/null +++ b/devtools/server/tests/unit/addons/web-extension/manifest.json @@ -0,0 +1,10 @@ +{ + "manifest_version": 2, + "name": "Test Addons Actor", + "version": "1.0", + "applications": { + "gecko": { + "id": "test-addons-actor@mozilla.org" + } + } +} diff --git a/devtools/server/tests/unit/addons/web-extension2/manifest.json b/devtools/server/tests/unit/addons/web-extension2/manifest.json new file mode 100644 index 000000000..57daae29d --- /dev/null +++ b/devtools/server/tests/unit/addons/web-extension2/manifest.json @@ -0,0 +1,10 @@ +{ + "manifest_version": 2, + "name": "Test Addons Actor 2", + "version": "1.0", + "applications": { + "gecko": { + "id": "test-addons-actor2@mozilla.org" + } + } +} diff --git a/devtools/server/tests/unit/babel_and_browserify_script_with_source_map.js b/devtools/server/tests/unit/babel_and_browserify_script_with_source_map.js new file mode 100644 index 000000000..317eb68ca --- /dev/null +++ b/devtools/server/tests/unit/babel_and_browserify_script_with_source_map.js @@ -0,0 +1,79 @@ +(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ +"use strict"; + +var add = require("./lib/add"); +var subtract = require("./lib/subtract"); +var upperCase = require("upper-case"); + +(function () { + return add(1, 2); +}); + +},{"./lib/add":2,"./lib/subtract":3,"upper-case":4}],2:[function(require,module,exports){ +"use strict"; + +module.exports = function (a, b) { + return a + b; +}; + +},{}],3:[function(require,module,exports){ +"use strict"; + +module.exports = function (a, b) { + return a - b; +}; + +},{}],4:[function(require,module,exports){ +/** + * Special language-specific overrides. + * + * Source: ftp://ftp.unicode.org/Public/UCD/latest/ucd/SpecialCasing.txt + * + * @type {Object} + */ +var LANGUAGES = { + tr: { + regexp: /[\u0069]/g, + map: { + '\u0069': '\u0130' + } + }, + az: { + regexp: /[\u0069]/g, + map: { + '\u0069': '\u0130' + } + }, + lt: { + regexp: /[\u0069\u006A\u012F]\u0307|\u0069\u0307[\u0300\u0301\u0303]/g, + map: { + '\u0069\u0307': '\u0049', + '\u006A\u0307': '\u004A', + '\u012F\u0307': '\u012E', + '\u0069\u0307\u0300': '\u00CC', + '\u0069\u0307\u0301': '\u00CD', + '\u0069\u0307\u0303': '\u0128' + } + } +} + +/** + * Upper case a string. + * + * @param {String} str + * @return {String} + */ +module.exports = function (str, locale) { + var lang = LANGUAGES[locale] + + str = str == null ? '' : String(str) + + if (lang) { + str = str.replace(lang.regexp, function (m) { return lang.map[m] }) + } + + return str.toUpperCase() +} + +},{}]},{},[1]) +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3Vzci9sb2NhbC9saWIvbm9kZV9tb2R1bGVzL2Jyb3dzZXJpZnkvbm9kZV9tb2R1bGVzL2Jyb3dzZXItcGFjay9fcHJlbHVkZS5qcyIsIi9Vc2Vycy9qc2FudGVsbC9EZXYvc291cmNlLW1hcC10ZXN0L2luZGV4LmpzIiwiL1VzZXJzL2pzYW50ZWxsL0Rldi9zb3VyY2UtbWFwLXRlc3QvbGliL2FkZC5qcyIsIi9Vc2Vycy9qc2FudGVsbC9EZXYvc291cmNlLW1hcC10ZXN0L2xpYi9zdWJ0cmFjdC5qcyIsIm5vZGVfbW9kdWxlcy91cHBlci1jYXNlL3VwcGVyLWNhc2UuanMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7OztBQ0FBLElBQUksR0FBRyxHQUFHLE9BQU8sQ0FBQyxXQUFXLENBQUMsQ0FBQztBQUMvQixJQUFJLFFBQVEsR0FBRyxPQUFPLENBQUMsZ0JBQWdCLENBQUMsQ0FBQztBQUN6QyxJQUFJLFNBQVMsR0FBRyxPQUFPLENBQUMsWUFBWSxDQUFDLENBQUM7O0FBRXRDLENBQUM7U0FBTSxHQUFHLENBQUMsQ0FBQyxFQUFDLENBQUMsQ0FBQztFQUFBLENBQUU7Ozs7O0FDSmpCLE1BQU0sQ0FBQyxPQUFPLEdBQUcsVUFBVSxDQUFDLEVBQUUsQ0FBQyxFQUFFO0FBQy9CLFNBQU8sQ0FBQyxHQUFHLENBQUMsQ0FBQztDQUNkLENBQUM7Ozs7O0FDRkYsTUFBTSxDQUFDLE9BQU8sR0FBRyxVQUFVLENBQUMsRUFBRSxDQUFDLEVBQUU7QUFDL0IsU0FBTyxDQUFDLEdBQUcsQ0FBQyxDQUFDO0NBQ2QsQ0FBQzs7O0FDRkY7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBIiwiZmlsZSI6ImdlbmVyYXRlZC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzQ29udGVudCI6WyIoZnVuY3Rpb24gZSh0LG4scil7ZnVuY3Rpb24gcyhvLHUpe2lmKCFuW29dKXtpZighdFtvXSl7dmFyIGE9dHlwZW9mIHJlcXVpcmU9PVwiZnVuY3Rpb25cIiYmcmVxdWlyZTtpZighdSYmYSlyZXR1cm4gYShvLCEwKTtpZihpKXJldHVybiBpKG8sITApO3ZhciBmPW5ldyBFcnJvcihcIkNhbm5vdCBmaW5kIG1vZHVsZSAnXCIrbytcIidcIik7dGhyb3cgZi5jb2RlPVwiTU9EVUxFX05PVF9GT1VORFwiLGZ9dmFyIGw9bltvXT17ZXhwb3J0czp7fX07dFtvXVswXS5jYWxsKGwuZXhwb3J0cyxmdW5jdGlvbihlKXt2YXIgbj10W29dWzFdW2VdO3JldHVybiBzKG4/bjplKX0sbCxsLmV4cG9ydHMsZSx0LG4scil9cmV0dXJuIG5bb10uZXhwb3J0c312YXIgaT10eXBlb2YgcmVxdWlyZT09XCJmdW5jdGlvblwiJiZyZXF1aXJlO2Zvcih2YXIgbz0wO288ci5sZW5ndGg7bysrKXMocltvXSk7cmV0dXJuIHN9KSIsInZhciBhZGQgPSByZXF1aXJlKFwiLi9saWIvYWRkXCIpO1xudmFyIHN1YnRyYWN0ID0gcmVxdWlyZShcIi4vbGliL3N1YnRyYWN0XCIpO1xudmFyIHVwcGVyQ2FzZSA9IHJlcXVpcmUoXCJ1cHBlci1jYXNlXCIpO1xuXG4oKCkgPT4gYWRkKDEsMikpO1xuIiwibW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiAoYSwgYikge1xuICByZXR1cm4gYSArIGI7XG59O1xuIiwibW9kdWxlLmV4cG9ydHMgPSBmdW5jdGlvbiAoYSwgYikge1xuICByZXR1cm4gYSAtIGI7XG59O1xuIiwiLyoqXG4gKiBTcGVjaWFsIGxhbmd1YWdlLXNwZWNpZmljIG92ZXJyaWRlcy5cbiAqXG4gKiBTb3VyY2U6IGZ0cDovL2Z0cC51bmljb2RlLm9yZy9QdWJsaWMvVUNEL2xhdGVzdC91Y2QvU3BlY2lhbENhc2luZy50eHRcbiAqXG4gKiBAdHlwZSB7T2JqZWN0fVxuICovXG52YXIgTEFOR1VBR0VTID0ge1xuICB0cjoge1xuICAgIHJlZ2V4cDogL1tcXHUwMDY5XS9nLFxuICAgIG1hcDoge1xuICAgICAgJ1xcdTAwNjknOiAnXFx1MDEzMCdcbiAgICB9XG4gIH0sXG4gIGF6OiB7XG4gICAgcmVnZXhwOiAvW1xcdTAwNjldL2csXG4gICAgbWFwOiB7XG4gICAgICAnXFx1MDA2OSc6ICdcXHUwMTMwJ1xuICAgIH1cbiAgfSxcbiAgbHQ6IHtcbiAgICByZWdleHA6IC9bXFx1MDA2OVxcdTAwNkFcXHUwMTJGXVxcdTAzMDd8XFx1MDA2OVxcdTAzMDdbXFx1MDMwMFxcdTAzMDFcXHUwMzAzXS9nLFxuICAgIG1hcDoge1xuICAgICAgJ1xcdTAwNjlcXHUwMzA3JzogJ1xcdTAwNDknLFxuICAgICAgJ1xcdTAwNkFcXHUwMzA3JzogJ1xcdTAwNEEnLFxuICAgICAgJ1xcdTAxMkZcXHUwMzA3JzogJ1xcdTAxMkUnLFxuICAgICAgJ1xcdTAwNjlcXHUwMzA3XFx1MDMwMCc6ICdcXHUwMENDJyxcbiAgICAgICdcXHUwMDY5XFx1MDMwN1xcdTAzMDEnOiAnXFx1MDBDRCcsXG4gICAgICAnXFx1MDA2OVxcdTAzMDdcXHUwMzAzJzogJ1xcdTAxMjgnXG4gICAgfVxuICB9XG59XG5cbi8qKlxuICogVXBwZXIgY2FzZSBhIHN0cmluZy5cbiAqXG4gKiBAcGFyYW0gIHtTdHJpbmd9IHN0clxuICogQHJldHVybiB7U3RyaW5nfVxuICovXG5tb2R1bGUuZXhwb3J0cyA9IGZ1bmN0aW9uIChzdHIsIGxvY2FsZSkge1xuICB2YXIgbGFuZyA9IExBTkdVQUdFU1tsb2NhbGVdXG5cbiAgc3RyID0gc3RyID09IG51bGwgPyAnJyA6IFN0cmluZyhzdHIpXG5cbiAgaWYgKGxhbmcpIHtcbiAgICBzdHIgPSBzdHIucmVwbGFjZShsYW5nLnJlZ2V4cCwgZnVuY3Rpb24gKG0pIHsgcmV0dXJuIGxhbmcubWFwW21dIH0pXG4gIH1cblxuICByZXR1cm4gc3RyLnRvVXBwZXJDYXNlKClcbn1cbiJdfQ== diff --git a/devtools/server/tests/unit/head_dbg.js b/devtools/server/tests/unit/head_dbg.js new file mode 100644 index 000000000..57d0eb8ff --- /dev/null +++ b/devtools/server/tests/unit/head_dbg.js @@ -0,0 +1,862 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +var Cc = Components.classes; +var Ci = Components.interfaces; +var Cu = Components.utils; +var Cr = Components.results; +var CC = Components.Constructor; + +// Populate AppInfo before anything (like the shared loader) accesses +// System.appinfo, which is a lazy getter. +const _appInfo = {}; +Cu.import("resource://testing-common/AppInfo.jsm", _appInfo); +_appInfo.updateAppInfo({ + ID: "devtools@tests.mozilla.org", + name: "devtools-tests", + version: "1", + platformVersion: "42", + crashReporter: true, +}); + +const { require, loader } = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const { worker } = Cu.import("resource://devtools/shared/worker/loader.js", {}); +const promise = require("promise"); +const { Task } = require("devtools/shared/task"); +const { console } = require("resource://gre/modules/Console.jsm"); +const { NetUtil } = require("resource://gre/modules/NetUtil.jsm"); + +const Services = require("Services"); +// Always log packets when running tests. runxpcshelltests.py will throw +// the output away anyway, unless you give it the --verbose flag. +Services.prefs.setBoolPref("devtools.debugger.log", true); +// Enable remote debugging for the relevant tests. +Services.prefs.setBoolPref("devtools.debugger.remote-enabled", true); + +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { DebuggerServer } = require("devtools/server/main"); +const { DebuggerServer: WorkerDebuggerServer } = worker.require("devtools/server/main"); +const { DebuggerClient, ObjectClient } = require("devtools/shared/client/main"); +const { MemoryFront } = require("devtools/shared/fronts/memory"); + +const { addDebuggerToGlobal } = Cu.import("resource://gre/modules/jsdebugger.jsm", {}); + +const systemPrincipal = Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal); + +var loadSubScript = Cc[ + "@mozilla.org/moz/jssubscript-loader;1" +].getService(Ci.mozIJSSubScriptLoader).loadSubScript; + +/** + * Initializes any test that needs to work with add-ons. + */ +function startupAddonsManager() { + // Create a directory for extensions. + const profileDir = do_get_profile().clone(); + profileDir.append("extensions"); + + const internalManager = Cc["@mozilla.org/addons/integration;1"] + .getService(Ci.nsIObserver) + .QueryInterface(Ci.nsITimerCallback); + + internalManager.observe(null, "addons-startup", null); +} + +/** + * Create a `run_test` function that runs the given generator in a task after + * having attached to a memory actor. When done, the memory actor is detached + * from, the client is finished, and the test is finished. + * + * @param {GeneratorFunction} testGeneratorFunction + * The generator function is passed (DebuggerClient, MemoryFront) + * arguments. + * + * @returns `run_test` function + */ +function makeMemoryActorTest(testGeneratorFunction) { + const TEST_GLOBAL_NAME = "test_MemoryActor"; + + return function run_test() { + do_test_pending(); + startTestDebuggerServer(TEST_GLOBAL_NAME).then(client => { + DebuggerServer.registerModule("devtools/server/actors/heap-snapshot-file", { + prefix: "heapSnapshotFile", + constructor: "HeapSnapshotFileActor", + type: { global: true } + }); + + getTestTab(client, TEST_GLOBAL_NAME, function (tabForm, rootForm) { + if (!tabForm || !rootForm) { + ok(false, "Could not attach to test tab: " + TEST_GLOBAL_NAME); + return; + } + + Task.spawn(function* () { + try { + const memoryFront = new MemoryFront(client, tabForm, rootForm); + yield memoryFront.attach(); + yield* testGeneratorFunction(client, memoryFront); + yield memoryFront.detach(); + } catch (err) { + DevToolsUtils.reportException("makeMemoryActorTest", err); + ok(false, "Got an error: " + err); + } + + finishClient(client); + }); + }); + }); + }; +} + +/** + * Save as makeMemoryActorTest but attaches the MemoryFront to the MemoryActor + * scoped to the full runtime rather than to a tab. + */ +function makeFullRuntimeMemoryActorTest(testGeneratorFunction) { + return function run_test() { + do_test_pending(); + startTestDebuggerServer("test_MemoryActor").then(client => { + DebuggerServer.registerModule("devtools/server/actors/heap-snapshot-file", { + prefix: "heapSnapshotFile", + constructor: "HeapSnapshotFileActor", + type: { global: true } + }); + + getChromeActors(client).then(function (form) { + if (!form) { + ok(false, "Could not attach to chrome actors"); + return; + } + + Task.spawn(function* () { + try { + const rootForm = yield listTabs(client); + const memoryFront = new MemoryFront(client, form, rootForm); + yield memoryFront.attach(); + yield* testGeneratorFunction(client, memoryFront); + yield memoryFront.detach(); + } catch (err) { + DevToolsUtils.reportException("makeMemoryActorTest", err); + ok(false, "Got an error: " + err); + } + + finishClient(client); + }); + }); + }); + }; +} + +function createTestGlobal(name) { + let sandbox = Cu.Sandbox( + Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal) + ); + sandbox.__name = name; + return sandbox; +} + +function connect(client) { + dump("Connecting client.\n"); + return client.connect(); +} + +function close(client) { + dump("Closing client.\n"); + return client.close(); +} + +function listTabs(client) { + dump("Listing tabs.\n"); + return client.listTabs(); +} + +function findTab(tabs, title) { + dump("Finding tab with title '" + title + "'.\n"); + for (let tab of tabs) { + if (tab.title === title) { + return tab; + } + } + return null; +} + +function attachTab(client, tab) { + dump("Attaching to tab with title '" + tab.title + "'.\n"); + return client.attachTab(tab.actor); +} + +function waitForNewSource(threadClient, url) { + dump("Waiting for new source with url '" + url + "'.\n"); + return waitForEvent(threadClient, "newSource", function (packet) { + return packet.source.url === url; + }); +} + +function attachThread(tabClient, options = {}) { + dump("Attaching to thread.\n"); + return tabClient.attachThread(options); +} + +function resume(threadClient) { + dump("Resuming thread.\n"); + return threadClient.resume(); +} + +function getSources(threadClient) { + dump("Getting sources.\n"); + return threadClient.getSources(); +} + +function findSource(sources, url) { + dump("Finding source with url '" + url + "'.\n"); + for (let source of sources) { + if (source.url === url) { + return source; + } + } + return null; +} + +function waitForPause(threadClient) { + dump("Waiting for pause.\n"); + return waitForEvent(threadClient, "paused"); +} + +function setBreakpoint(sourceClient, location) { + dump("Setting breakpoint.\n"); + return sourceClient.setBreakpoint(location); +} + +function dumpn(msg) { + dump("DBG-TEST: " + msg + "\n"); +} + +function testExceptionHook(ex) { + try { + do_report_unexpected_exception(ex); + } catch (ex) { + return {throw: ex}; + } + return undefined; +} + +// Convert an nsIScriptError 'aFlags' value into an appropriate string. +function scriptErrorFlagsToKind(aFlags) { + var kind; + if (aFlags & Ci.nsIScriptError.warningFlag) + kind = "warning"; + if (aFlags & Ci.nsIScriptError.exceptionFlag) + kind = "exception"; + else + kind = "error"; + + if (aFlags & Ci.nsIScriptError.strictFlag) + kind = "strict " + kind; + + return kind; +} + +// Register a console listener, so console messages don't just disappear +// into the ether. +var errorCount = 0; +var listener = { + observe: function (aMessage) { + try { + errorCount++; + try { + // If we've been given an nsIScriptError, then we can print out + // something nicely formatted, for tools like Emacs to pick up. + var scriptError = aMessage.QueryInterface(Ci.nsIScriptError); + dumpn(aMessage.sourceName + ":" + aMessage.lineNumber + ": " + + scriptErrorFlagsToKind(aMessage.flags) + ": " + + aMessage.errorMessage); + var string = aMessage.errorMessage; + } catch (x) { + // Be a little paranoid with message, as the whole goal here is to lose + // no information. + try { + var string = "" + aMessage.message; + } catch (x) { + var string = "<error converting error message to string>"; + } + } + + // Make sure we exit all nested event loops so that the test can finish. + while (DebuggerServer + && DebuggerServer.xpcInspector + && DebuggerServer.xpcInspector.eventLoopNestLevel > 0) { + DebuggerServer.xpcInspector.exitNestedEventLoop(); + } + + // In the world before bug 997440, exceptions were getting lost because of + // the arbitrary JSContext being used in nsXPCWrappedJSClass::CallMethod. + // In the new world, the wanderers have returned. However, because of the, + // currently very-broken, exception reporting machinery in + // XPCWrappedJSClass these get reported as errors to the console, even if + // there's actually JS on the stack above that will catch them. If we + // throw an error here because of them our tests start failing. So, we'll + // just dump the message to the logs instead, to make sure the information + // isn't lost. + dumpn("head_dbg.js observed a console message: " + string); + } catch (_) { + // Swallow everything to avoid console reentrancy errors. We did our best + // to log above, but apparently that didn't cut it. + } + } +}; + +var consoleService = Cc["@mozilla.org/consoleservice;1"].getService(Ci.nsIConsoleService); +consoleService.registerListener(listener); + +function check_except(func) +{ + try { + func(); + } catch (e) { + do_check_true(true); + return; + } + dumpn("Should have thrown an exception: " + func.toString()); + do_check_true(false); +} + +function testGlobal(aName) { + let systemPrincipal = Cc["@mozilla.org/systemprincipal;1"] + .createInstance(Ci.nsIPrincipal); + + let sandbox = Cu.Sandbox(systemPrincipal); + sandbox.__name = aName; + return sandbox; +} + +function addTestGlobal(aName, aServer = DebuggerServer) +{ + let global = testGlobal(aName); + aServer.addTestGlobal(global); + return global; +} + +// List the DebuggerClient |aClient|'s tabs, look for one whose title is +// |aTitle|, and apply |aCallback| to the packet's entry for that tab. +function getTestTab(aClient, aTitle, aCallback) { + aClient.listTabs(function (aResponse) { + for (let tab of aResponse.tabs) { + if (tab.title === aTitle) { + aCallback(tab, aResponse); + return; + } + } + aCallback(null); + }); +} + +// Attach to |aClient|'s tab whose title is |aTitle|; pass |aCallback| the +// response packet and a TabClient instance referring to that tab. +function attachTestTab(aClient, aTitle, aCallback) { + getTestTab(aClient, aTitle, function (aTab) { + aClient.attachTab(aTab.actor, aCallback); + }); +} + +// Attach to |aClient|'s tab whose title is |aTitle|, and then attach to +// that tab's thread. Pass |aCallback| the thread attach response packet, a +// TabClient referring to the tab, and a ThreadClient referring to the +// thread. +function attachTestThread(aClient, aTitle, aCallback) { + attachTestTab(aClient, aTitle, function (aTabResponse, aTabClient) { + function onAttach(aResponse, aThreadClient) { + aCallback(aResponse, aTabClient, aThreadClient, aTabResponse); + } + aTabClient.attachThread({ + useSourceMaps: true, + autoBlackBox: true + }, onAttach); + }); +} + +// Attach to |aClient|'s tab whose title is |aTitle|, attach to the tab's +// thread, and then resume it. Pass |aCallback| the thread's response to +// the 'resume' packet, a TabClient for the tab, and a ThreadClient for the +// thread. +function attachTestTabAndResume(aClient, aTitle, aCallback = () => {}) { + return new Promise((resolve, reject) => { + attachTestThread(aClient, aTitle, function (aResponse, aTabClient, aThreadClient) { + aThreadClient.resume(function (aResponse) { + aCallback(aResponse, aTabClient, aThreadClient); + resolve([aResponse, aTabClient, aThreadClient]); + }); + }); + }); +} + +/** + * Initialize the testing debugger server. + */ +function initTestDebuggerServer(aServer = DebuggerServer) +{ + aServer.registerModule("xpcshell-test/testactors"); + // Allow incoming connections. + aServer.init(function () { return true; }); +} + +/** + * Initialize the testing debugger server with a tab whose title is |title|. + */ +function startTestDebuggerServer(title, server = DebuggerServer) { + initTestDebuggerServer(server); + addTestGlobal(title); + DebuggerServer.addTabActors(); + + let transport = DebuggerServer.connectPipe(); + let client = new DebuggerClient(transport); + + return connect(client).then(() => client); +} + +function finishClient(aClient) +{ + aClient.close(function () { + DebuggerServer.destroy(); + do_test_finished(); + }); +} + +// Create a server, connect to it and fetch tab actors for the parent process; +// pass |aCallback| the debugger client and tab actor form with all actor IDs. +function get_chrome_actors(callback) +{ + 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); + }); +} + +function getChromeActors(client, server = DebuggerServer) { + server.allowChromeProcess = true; + return client.getProcess().then(response => response.form); +} + +/** + * Takes a relative file path and returns the absolute file url for it. + */ +function getFileUrl(aName, aAllowMissing = false) { + let file = do_get_file(aName, aAllowMissing); + return Services.io.newFileURI(file).spec; +} + +/** + * Returns the full path of the file with the specified name in a + * platform-independent and URL-like form. + */ +function getFilePath(aName, aAllowMissing = false, aUsePlatformPathSeparator = false) +{ + let file = do_get_file(aName, aAllowMissing); + let path = Services.io.newFileURI(file).spec; + let filePrePath = "file://"; + if ("nsILocalFileWin" in Ci && + file instanceof Ci.nsILocalFileWin) { + filePrePath += "/"; + } + + path = path.slice(filePrePath.length); + + if (aUsePlatformPathSeparator && path.match(/^\w:/)) { + path = path.replace(/\//g, "\\"); + } + + return path; +} + +/** + * Returns the full text contents of the given file. + */ +function readFile(aFileName) { + let f = do_get_file(aFileName); + let s = Cc["@mozilla.org/network/file-input-stream;1"] + .createInstance(Ci.nsIFileInputStream); + s.init(f, -1, -1, false); + try { + return NetUtil.readInputStreamToString(s, s.available()); + } finally { + s.close(); + } +} + +function writeFile(aFileName, aContent) { + let file = do_get_file(aFileName, true); + let stream = Cc["@mozilla.org/network/file-output-stream;1"] + .createInstance(Ci.nsIFileOutputStream); + stream.init(file, -1, -1, 0); + try { + do { + let numWritten = stream.write(aContent, aContent.length); + aContent = aContent.slice(numWritten); + } while (aContent.length > 0); + } finally { + stream.close(); + } +} + +function connectPipeTracing() { + return new TracingTransport(DebuggerServer.connectPipe()); +} + +function TracingTransport(childTransport) { + this.hooks = null; + this.child = childTransport; + this.child.hooks = this; + + this.expectations = []; + this.packets = []; + this.checkIndex = 0; +} + +TracingTransport.prototype = { + // Remove actor names + normalize: function (packet) { + return JSON.parse(JSON.stringify(packet, (key, value) => { + if (key === "to" || key === "from" || key === "actor") { + return "<actorid>"; + } + return value; + })); + }, + send: function (packet) { + this.packets.push({ + type: "sent", + packet: this.normalize(packet) + }); + return this.child.send(packet); + }, + close: function () { + return this.child.close(); + }, + ready: function () { + return this.child.ready(); + }, + onPacket: function (packet) { + this.packets.push({ + type: "received", + packet: this.normalize(packet) + }); + this.hooks.onPacket(packet); + }, + onClosed: function () { + this.hooks.onClosed(); + }, + + expectSend: function (expected) { + let packet = this.packets[this.checkIndex++]; + do_check_eq(packet.type, "sent"); + deepEqual(packet.packet, this.normalize(expected)); + }, + + expectReceive: function (expected) { + let packet = this.packets[this.checkIndex++]; + do_check_eq(packet.type, "received"); + deepEqual(packet.packet, this.normalize(expected)); + }, + + // Write your tests, call dumpLog at the end, inspect the output, + // then sprinkle the calls through the right places in your test. + dumpLog: function () { + for (let entry of this.packets) { + if (entry.type === "sent") { + dumpn("trace.expectSend(" + entry.packet + ");"); + } else { + dumpn("trace.expectReceive(" + entry.packet + ");"); + } + } + } +}; + +function StubTransport() { } +StubTransport.prototype.ready = function () {}; +StubTransport.prototype.send = function () {}; +StubTransport.prototype.close = function () {}; + +function executeSoon(aFunc) { + Services.tm.mainThread.dispatch({ + run: DevToolsUtils.makeInfallible(aFunc) + }, Ci.nsIThread.DISPATCH_NORMAL); +} + +// The do_check_* family of functions expect their last argument to be an +// optional stack object. Unfortunately, most tests actually pass a in a string +// containing an error message instead, which causes error reporting to break if +// strict warnings as errors is turned on. To avoid this, we wrap these +// functions here below to ensure the correct number of arguments is passed. +// +// TODO: Remove this once bug 906232 is resolved +// +var do_check_true_old = do_check_true; +var do_check_true = function (condition) { + do_check_true_old(condition); +}; + +var do_check_false_old = do_check_false; +var do_check_false = function (condition) { + do_check_false_old(condition); +}; + +var do_check_eq_old = do_check_eq; +var do_check_eq = function (left, right) { + do_check_eq_old(left, right); +}; + +var do_check_neq_old = do_check_neq; +var do_check_neq = function (left, right) { + do_check_neq_old(left, right); +}; + +var do_check_matches_old = do_check_matches; +var do_check_matches = function (pattern, value) { + do_check_matches_old(pattern, value); +}; + +// Create async version of the object where calling each method +// is equivalent of calling it with asyncall. Mainly useful for +// destructuring objects with methods that take callbacks. +const Async = target => new Proxy(target, Async); +Async.get = (target, name) => + typeof (target[name]) === "function" ? asyncall.bind(null, target[name], target) : + target[name]; + +// Calls async function that takes callback and errorback and returns +// returns promise representing result. +const asyncall = (fn, self, ...args) => + new Promise((...etc) => fn.call(self, ...args, ...etc)); + +const Test = task => () => { + add_task(task); + run_next_test(); +}; + +const assert = do_check_true; + +/** + * Create a promise that is resolved on the next occurence of the given event. + * + * @param DebuggerClient client + * @param String event + * @param Function predicate + * @returns Promise + */ +function waitForEvent(client, type, predicate) { + return new Promise(function (resolve) { + function listener(type, packet) { + if (!predicate(packet)) { + return; + } + client.removeListener(listener); + resolve(packet); + } + + if (predicate) { + client.addListener(type, listener); + } else { + client.addOneTimeListener(type, function (type, packet) { + resolve(packet); + }); + } + }); +} + +/** + * Execute the action on the next tick and return a promise that is resolved on + * the next pause. + * + * When using promises and Task.jsm, we often want to do an action that causes a + * pause and continue the task once the pause has ocurred. Unfortunately, if we + * do the action that causes the pause within the task's current tick we will + * pause before we have a chance to yield the promise that waits for the pause + * and we enter a dead lock. The solution is to create the promise that waits + * for the pause, schedule the action to run on the next tick of the event loop, + * and finally yield the promise. + * + * @param Function action + * @param DebuggerClient client + * @returns Promise + */ +function executeOnNextTickAndWaitForPause(action, client) { + const paused = waitForPause(client); + executeSoon(action); + return paused; +} + +/** + * Interrupt JS execution for the specified thread. + * + * @param ThreadClient threadClient + * @returns Promise + */ +function interrupt(threadClient) { + dumpn("Interrupting."); + return threadClient.interrupt(); +} + +/** + * Resume JS execution for the specified thread and then wait for the next pause + * event. + * + * @param DebuggerClient client + * @param ThreadClient threadClient + * @returns Promise + */ +function resumeAndWaitForPause(client, threadClient) { + const paused = waitForPause(client); + return resume(threadClient).then(() => paused); +} + +/** + * Resume JS execution for a single step and wait for the pause after the step + * has been taken. + * + * @param DebuggerClient client + * @param ThreadClient threadClient + * @returns Promise + */ +function stepIn(client, threadClient) { + dumpn("Stepping in."); + const paused = waitForPause(client); + return threadClient.stepIn() + .then(() => paused); +} + +/** + * Resume JS execution for a step over and wait for the pause after the step + * has been taken. + * + * @param DebuggerClient client + * @param ThreadClient threadClient + * @returns Promise + */ +function stepOver(client, threadClient) { + dumpn("Stepping over."); + return threadClient.stepOver() + .then(() => waitForPause(client)); +} + +/** + * Get the list of `count` frames currently on stack, starting at the index + * `first` for the specified thread. + * + * @param ThreadClient threadClient + * @param Number first + * @param Number count + * @returns Promise + */ +function getFrames(threadClient, first, count) { + dumpn("Getting frames."); + return threadClient.getFrames(first, count); +} + +/** + * Black box the specified source. + * + * @param SourceClient sourceClient + * @returns Promise + */ +function blackBox(sourceClient) { + dumpn("Black boxing source: " + sourceClient.actor); + return sourceClient.blackBox(); +} + +/** + * Stop black boxing the specified source. + * + * @param SourceClient sourceClient + * @returns Promise + */ +function unBlackBox(sourceClient) { + dumpn("Un-black boxing source: " + sourceClient.actor); + return sourceClient.unblackBox(); +} + +/** + * Perform a "source" RDP request with the given SourceClient to get the source + * content and content type. + * + * @param SourceClient sourceClient + * @returns Promise + */ +function getSourceContent(sourceClient) { + dumpn("Getting source content for " + sourceClient.actor); + return sourceClient.source(); +} + +/** + * Get a source at the specified url. + * + * @param ThreadClient threadClient + * @param string url + * @returns Promise<SourceClient> + */ +function getSource(threadClient, url) { + let deferred = promise.defer(); + threadClient.getSources((res) => { + let source = res.sources.filter(function (s) { + return s.url === url; + }); + if (source.length) { + deferred.resolve(threadClient.source(source[0])); + } + else { + deferred.reject(new Error("source not found")); + } + }); + return deferred.promise; +} + +/** + * Do a fake reload which clears the thread debugger + * + * @param TabClient tabClient + * @returns Promise<response> + */ +function reload(tabClient) { + let deferred = promise.defer(); + tabClient._reload({}, deferred.resolve); + return deferred.promise; +} + +/** + * Returns an array of stack location strings given a thread and a sample. + * + * @param object thread + * @param object sample + * @returns object + */ +function getInflatedStackLocations(thread, sample) { + let stackTable = thread.stackTable; + let frameTable = thread.frameTable; + let stringTable = thread.stringTable; + let SAMPLE_STACK_SLOT = thread.samples.schema.stack; + let STACK_PREFIX_SLOT = stackTable.schema.prefix; + let STACK_FRAME_SLOT = stackTable.schema.frame; + let FRAME_LOCATION_SLOT = frameTable.schema.location; + + // Build the stack from the raw data and accumulate the locations in + // an array. + let stackIndex = sample[SAMPLE_STACK_SLOT]; + let locations = []; + while (stackIndex !== null) { + let stackEntry = stackTable.data[stackIndex]; + let frame = frameTable.data[stackEntry[STACK_FRAME_SLOT]]; + locations.push(stringTable[frame[FRAME_LOCATION_SLOT]]); + stackIndex = stackEntry[STACK_PREFIX_SLOT]; + } + + // The profiler tree is inverted, so reverse the array. + return locations.reverse(); +} diff --git a/devtools/server/tests/unit/hello-actor.js b/devtools/server/tests/unit/hello-actor.js new file mode 100644 index 000000000..6d7427f63 --- /dev/null +++ b/devtools/server/tests/unit/hello-actor.js @@ -0,0 +1,18 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const protocol = require("devtools/shared/protocol"); + +const helloSpec = protocol.generateActorSpec({ + typeName: "helloActor", + + methods: { + hello: {} + } +}); + +var HelloActor = protocol.ActorClassWithSpec(helloSpec, { + hello: function () { + return; + } +}); diff --git a/devtools/server/tests/unit/post_init_global_actors.js b/devtools/server/tests/unit/post_init_global_actors.js new file mode 100644 index 000000000..0035e8914 --- /dev/null +++ b/devtools/server/tests/unit/post_init_global_actors.js @@ -0,0 +1,17 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function PostInitGlobalActor(aConnection) {} + +PostInitGlobalActor.prototype = { + actorPrefix: "postInitGlobal", + onPing: function onPing(aRequest) { + return { message: "pong" }; + }, +}; + +PostInitGlobalActor.prototype.requestTypes = { + "ping": PostInitGlobalActor.prototype.onPing, +}; + +DebuggerServer.addGlobalActor(PostInitGlobalActor, "postInitGlobalActor"); diff --git a/devtools/server/tests/unit/post_init_tab_actors.js b/devtools/server/tests/unit/post_init_tab_actors.js new file mode 100644 index 000000000..9b9ddf111 --- /dev/null +++ b/devtools/server/tests/unit/post_init_tab_actors.js @@ -0,0 +1,17 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function PostInitTabActor(aConnection) {} + +PostInitTabActor.prototype = { + actorPostfix: "postInitTab", + onPing: function onPing(aRequest) { + return { message: "pong" }; + }, +}; + +PostInitTabActor.prototype.requestTypes = { + "ping": PostInitTabActor.prototype.onPing, +}; + +DebuggerServer.addGlobalActor(PostInitTabActor, "postInitTabActor"); diff --git a/devtools/server/tests/unit/pre_init_global_actors.js b/devtools/server/tests/unit/pre_init_global_actors.js new file mode 100644 index 000000000..bd4284a70 --- /dev/null +++ b/devtools/server/tests/unit/pre_init_global_actors.js @@ -0,0 +1,17 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function PreInitGlobalActor(aConnection) {} + +PreInitGlobalActor.prototype = { + actorPrefix: "preInitGlobal", + onPing: function onPing(aRequest) { + return { message: "pong" }; + }, +}; + +PreInitGlobalActor.prototype.requestTypes = { + "ping": PreInitGlobalActor.prototype.onPing, +}; + +DebuggerServer.addGlobalActor(PreInitGlobalActor, "preInitGlobalActor"); diff --git a/devtools/server/tests/unit/pre_init_tab_actors.js b/devtools/server/tests/unit/pre_init_tab_actors.js new file mode 100644 index 000000000..628f0fb2f --- /dev/null +++ b/devtools/server/tests/unit/pre_init_tab_actors.js @@ -0,0 +1,17 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function PreInitTabActor(aConnection) {} + +PreInitTabActor.prototype = { + actorPrefix: "preInitTab", + onPing: function onPing(aRequest) { + return { message: "pong" }; + }, +}; + +PreInitTabActor.prototype.requestTypes = { + "ping": PreInitTabActor.prototype.onPing, +}; + +DebuggerServer.addGlobalActor(PreInitTabActor, "preInitTabActor"); diff --git a/devtools/server/tests/unit/registertestactors-01.js b/devtools/server/tests/unit/registertestactors-01.js new file mode 100644 index 000000000..92f511225 --- /dev/null +++ b/devtools/server/tests/unit/registertestactors-01.js @@ -0,0 +1,15 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function Actor() {} + +exports.register = function (handle) { + handle.addTabActor(Actor, "registeredActor1"); + handle.addGlobalActor(Actor, "registeredActor1"); +}; + +exports.unregister = function (handle) { + handle.removeTabActor(Actor); + handle.removeGlobalActor(Actor); +}; + diff --git a/devtools/server/tests/unit/registertestactors-02.js b/devtools/server/tests/unit/registertestactors-02.js new file mode 100644 index 000000000..54f78e508 --- /dev/null +++ b/devtools/server/tests/unit/registertestactors-02.js @@ -0,0 +1,15 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function Actor() {} + +exports.register = function (handle) { + handle.addGlobalActor(Actor, "registeredActor2"); + handle.addTabActor(Actor, "registeredActor2"); +}; + +exports.unregister = function (handle) { + handle.removeTabActor(Actor); + handle.removeGlobalActor(Actor); +}; + diff --git a/devtools/server/tests/unit/registertestactors-03.js b/devtools/server/tests/unit/registertestactors-03.js new file mode 100644 index 000000000..8d42fdbd8 --- /dev/null +++ b/devtools/server/tests/unit/registertestactors-03.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var {method, RetVal, Actor, ActorClassWithSpec, Front, FrontClassWithSpec, + generateActorSpec} = require("devtools/shared/protocol"); +var Services = require("Services"); + +const lazySpec = generateActorSpec({ + typeName: "lazy", + + methods: { + hello: { + response: { str: RetVal("string") } + } + } +}); + +exports.LazyActor = ActorClassWithSpec(lazySpec, { + initialize: function (conn, id) { + Actor.prototype.initialize.call(this, conn); + + Services.obs.notifyObservers(null, "actor", "instantiated"); + }, + + hello: function (str) { + return "world"; + } +}); + +Services.obs.notifyObservers(null, "actor", "loaded"); + +exports.LazyFront = FrontClassWithSpec(lazySpec, { + initialize: function (client, form) { + Front.prototype.initialize.call(this, client); + this.actorID = form.lazyActor; + + client.addActorPool(this); + this.manage(this); + } +}); diff --git a/devtools/server/tests/unit/setBreakpoint-on-column-in-gcd-script.js b/devtools/server/tests/unit/setBreakpoint-on-column-in-gcd-script.js new file mode 100644 index 000000000..575915c4f --- /dev/null +++ b/devtools/server/tests/unit/setBreakpoint-on-column-in-gcd-script.js @@ -0,0 +1,7 @@ +"use strict"; + +function f() {} + +(function () { + var a = 1; var b = 2; var c = 3; +})(); diff --git a/devtools/server/tests/unit/setBreakpoint-on-column-with-no-offsets-at-end-of-line.js b/devtools/server/tests/unit/setBreakpoint-on-column-with-no-offsets-at-end-of-line.js new file mode 100644 index 000000000..4c1b52eb4 --- /dev/null +++ b/devtools/server/tests/unit/setBreakpoint-on-column-with-no-offsets-at-end-of-line.js @@ -0,0 +1,6 @@ +"use strict"; + +function f() { + var a = 1; var b = 2; + var c = 3; +} diff --git a/devtools/server/tests/unit/setBreakpoint-on-column-with-no-offsets-in-gcd-script.js b/devtools/server/tests/unit/setBreakpoint-on-column-with-no-offsets-in-gcd-script.js new file mode 100644 index 000000000..adce39193 --- /dev/null +++ b/devtools/server/tests/unit/setBreakpoint-on-column-with-no-offsets-in-gcd-script.js @@ -0,0 +1,7 @@ +"use strict"; + +function f() {} + +(function () { + var a = 1; var c = 3; +})(); diff --git a/devtools/server/tests/unit/setBreakpoint-on-column-with-no-offsets.js b/devtools/server/tests/unit/setBreakpoint-on-column-with-no-offsets.js new file mode 100644 index 000000000..5faefc3c8 --- /dev/null +++ b/devtools/server/tests/unit/setBreakpoint-on-column-with-no-offsets.js @@ -0,0 +1,5 @@ +"use strict"; + +function f() { + var a = 1; var c = 3; +} diff --git a/devtools/server/tests/unit/setBreakpoint-on-column.js b/devtools/server/tests/unit/setBreakpoint-on-column.js new file mode 100644 index 000000000..d92231e65 --- /dev/null +++ b/devtools/server/tests/unit/setBreakpoint-on-column.js @@ -0,0 +1,5 @@ +"use strict"; + +function f() { + var a = 1; var b = 2; var c = 3; +} diff --git a/devtools/server/tests/unit/setBreakpoint-on-line-in-gcd-script.js b/devtools/server/tests/unit/setBreakpoint-on-line-in-gcd-script.js new file mode 100644 index 000000000..fb96be8ab --- /dev/null +++ b/devtools/server/tests/unit/setBreakpoint-on-line-in-gcd-script.js @@ -0,0 +1,9 @@ +"use strict"; + +function f() {} + +(function () { + var a = 1; + var b = 2; + var c = 3; +})(); diff --git a/devtools/server/tests/unit/setBreakpoint-on-line-with-multiple-offsets.js b/devtools/server/tests/unit/setBreakpoint-on-line-with-multiple-offsets.js new file mode 100644 index 000000000..b30ebb504 --- /dev/null +++ b/devtools/server/tests/unit/setBreakpoint-on-line-with-multiple-offsets.js @@ -0,0 +1,7 @@ +"use strict"; + +function f() { + for (var i = 0; i < 1; ++i) { + ; + } +} diff --git a/devtools/server/tests/unit/setBreakpoint-on-line-with-multiple-statements.js b/devtools/server/tests/unit/setBreakpoint-on-line-with-multiple-statements.js new file mode 100644 index 000000000..d92231e65 --- /dev/null +++ b/devtools/server/tests/unit/setBreakpoint-on-line-with-multiple-statements.js @@ -0,0 +1,5 @@ +"use strict"; + +function f() { + var a = 1; var b = 2; var c = 3; +} diff --git a/devtools/server/tests/unit/setBreakpoint-on-line-with-no-offsets-in-gcd-script.js b/devtools/server/tests/unit/setBreakpoint-on-line-with-no-offsets-in-gcd-script.js new file mode 100644 index 000000000..b03d40079 --- /dev/null +++ b/devtools/server/tests/unit/setBreakpoint-on-line-with-no-offsets-in-gcd-script.js @@ -0,0 +1,9 @@ +"use strict"; + +function f() {} + +(function () { + var a = 1; + + var c = 3; +})(); diff --git a/devtools/server/tests/unit/setBreakpoint-on-line-with-no-offsets.js b/devtools/server/tests/unit/setBreakpoint-on-line-with-no-offsets.js new file mode 100644 index 000000000..1268cf8db --- /dev/null +++ b/devtools/server/tests/unit/setBreakpoint-on-line-with-no-offsets.js @@ -0,0 +1,7 @@ +"use strict"; + +function f() { + var a = 1; + + var c = 3; +} diff --git a/devtools/server/tests/unit/setBreakpoint-on-line.js b/devtools/server/tests/unit/setBreakpoint-on-line.js new file mode 100644 index 000000000..1b15e2a5e --- /dev/null +++ b/devtools/server/tests/unit/setBreakpoint-on-line.js @@ -0,0 +1,7 @@ +"use strict"; + +function f() { + var a = 1; + var b = 2; + var c = 3; +} diff --git a/devtools/server/tests/unit/source-map-data/sourcemapped.coffee b/devtools/server/tests/unit/source-map-data/sourcemapped.coffee new file mode 100644 index 000000000..73a400a21 --- /dev/null +++ b/devtools/server/tests/unit/source-map-data/sourcemapped.coffee @@ -0,0 +1,6 @@ +foo = (n) -> + return "foo" + i for i in [0...n] + +[first, second, third] = foo(3) + +debugger
\ No newline at end of file diff --git a/devtools/server/tests/unit/source-map-data/sourcemapped.map b/devtools/server/tests/unit/source-map-data/sourcemapped.map new file mode 100644 index 000000000..dcee3c33c --- /dev/null +++ b/devtools/server/tests/unit/source-map-data/sourcemapped.map @@ -0,0 +1,10 @@ +{ + "version": 3, + "file": "sourcemapped.js", + "sourceRoot": "", + "sources": [ + "sourcemapped.coffee" + ], + "names": [], + "mappings": ";AAAA;CAAA,KAAA,yBAAA;CAAA;CAAA,CAAA,CAAA,MAAO;CACL,IAAA,GAAA;AAAA,CAAA,EAAA,MAA0B,qDAA1B;CAAA,EAAe,EAAR,QAAA;CAAP,IADI;CAAN,EAAM;;CAAN,CAGA,CAAyB,IAAA;;CAEzB,UALA;CAAA" +}
\ No newline at end of file diff --git a/devtools/server/tests/unit/sourcemapped.js b/devtools/server/tests/unit/sourcemapped.js new file mode 100644 index 000000000..94d130903 --- /dev/null +++ b/devtools/server/tests/unit/sourcemapped.js @@ -0,0 +1,16 @@ +// Generated by CoffeeScript 1.6.1 +(function () { + var first, foo, second, third, _ref; + + foo = function (n) { + var i, _i; + for (i = _i = 0; 0 <= n ? _i < n : _i > n; i = 0 <= n ? ++_i : --_i) { + return "foo" + i; + } + }; + + _ref = foo(3), first = _ref[0], second = _ref[1], third = _ref[2]; + + debugger; + +}).call(this); diff --git a/devtools/server/tests/unit/test_MemoryActor_saveHeapSnapshot_01.js b/devtools/server/tests/unit/test_MemoryActor_saveHeapSnapshot_01.js new file mode 100644 index 000000000..2fd38d49c --- /dev/null +++ b/devtools/server/tests/unit/test_MemoryActor_saveHeapSnapshot_01.js @@ -0,0 +1,18 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we can tell the memory actor to take a heap snapshot over the RDP +// and then create a HeapSnapshot instance from the resulting file. + +const { OS } = require("resource://gre/modules/osfile.jsm"); + +const run_test = makeMemoryActorTest(function* (client, memoryFront) { + const snapshotFilePath = yield memoryFront.saveHeapSnapshot(); + ok(!!(yield OS.File.stat(snapshotFilePath)), + "Should have the heap snapshot file"); + const snapshot = ThreadSafeChromeUtils.readHeapSnapshot(snapshotFilePath); + ok(snapshot instanceof HeapSnapshot, + "And we should be able to read a HeapSnapshot instance from the file"); +}); diff --git a/devtools/server/tests/unit/test_MemoryActor_saveHeapSnapshot_02.js b/devtools/server/tests/unit/test_MemoryActor_saveHeapSnapshot_02.js new file mode 100644 index 000000000..564ec0d06 --- /dev/null +++ b/devtools/server/tests/unit/test_MemoryActor_saveHeapSnapshot_02.js @@ -0,0 +1,20 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we can properly stream heap snapshot files over the RDP as bulk +// data. + +const { OS } = require("resource://gre/modules/osfile.jsm"); + +const run_test = makeMemoryActorTest(function* (client, memoryFront) { + const snapshotFilePath = yield memoryFront.saveHeapSnapshot({ + forceCopy: true + }); + ok(!!(yield OS.File.stat(snapshotFilePath)), + "Should have the heap snapshot file"); + const snapshot = ThreadSafeChromeUtils.readHeapSnapshot(snapshotFilePath); + ok(snapshot instanceof HeapSnapshot, + "And we should be able to read a HeapSnapshot instance from the file"); +}); diff --git a/devtools/server/tests/unit/test_MemoryActor_saveHeapSnapshot_03.js b/devtools/server/tests/unit/test_MemoryActor_saveHeapSnapshot_03.js new file mode 100644 index 000000000..e9e81594d --- /dev/null +++ b/devtools/server/tests/unit/test_MemoryActor_saveHeapSnapshot_03.js @@ -0,0 +1,18 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we can save full runtime heap snapshots when attached to the +// ChromeActor or a ChildProcessActor. + +const { OS } = require("resource://gre/modules/osfile.jsm"); + +const run_test = makeFullRuntimeMemoryActorTest(function* (client, memoryFront) { + const snapshotFilePath = yield memoryFront.saveHeapSnapshot(); + ok(!!(yield OS.File.stat(snapshotFilePath)), + "Should have the heap snapshot file"); + const snapshot = ThreadSafeChromeUtils.readHeapSnapshot(snapshotFilePath); + ok(snapshot instanceof HeapSnapshot, + "And we should be able to read a HeapSnapshot instance from the file"); +}); diff --git a/devtools/server/tests/unit/test_actor-registry-actor.js b/devtools/server/tests/unit/test_actor-registry-actor.js new file mode 100644 index 000000000..8b0abfbbb --- /dev/null +++ b/devtools/server/tests/unit/test_actor-registry-actor.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that you can register new actors via the ActorRegistrationActor. + */ + +var gClient; +var gRegistryFront; +var gActorFront; +var gOldPref; + +const { ActorRegistryFront } = require("devtools/shared/fronts/actor-registry"); + +function run_test() +{ + gOldPref = Services.prefs.getBoolPref("devtools.debugger.forbid-certified-apps"); + Services.prefs.setBoolPref("devtools.debugger.forbid-certified-apps", false); + initTestDebuggerServer(); + DebuggerServer.addBrowserActors(); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(getRegistry); + do_test_pending(); +} + +function getRegistry() { + gClient.listTabs((response) => { + gRegistryFront = ActorRegistryFront(gClient, response); + registerNewActor(); + }); +} + +function registerNewActor() { + let options = { + prefix: "helloActor", + constructor: "HelloActor", + type: { global: true } + }; + + gRegistryFront + .registerActor("resource://test/hello-actor.js", options) + .then(actorFront => gActorFront = actorFront) + .then(talkToNewActor) + .then(null, e => { + DevToolsUtils.reportException("registerNewActor", e); + do_check_true(false); + }); +} + +function talkToNewActor() { + gClient.listTabs(({ helloActor }) => { + do_check_true(!!helloActor); + gClient.request({ + to: helloActor, + type: "hello" + }, response => { + do_check_true(!response.error); + unregisterNewActor(); + }); + }); +} + +function unregisterNewActor() { + gActorFront + .unregister() + .then(testActorIsUnregistered) + .then(null, e => { + DevToolsUtils.reportException("unregisterNewActor", e); + do_check_true(false); + }); +} + +function testActorIsUnregistered() { + gClient.listTabs(({ helloActor }) => { + do_check_true(!helloActor); + + Services.prefs.setBoolPref("devtools.debugger.forbid-certified-apps", gOldPref); + finishClient(gClient); + }); +} diff --git a/devtools/server/tests/unit/test_add_actors.js b/devtools/server/tests/unit/test_add_actors.js new file mode 100644 index 000000000..9b90da724 --- /dev/null +++ b/devtools/server/tests/unit/test_add_actors.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gClient; +var gActors; + +/** + * The purpose of these tests is to verify that it's possible to add actors + * both before and after the DebuggerServer has been initialized, so addons + * that add actors don't have to poll the object for its initialization state + * in order to add actors after initialization but rather can add actors anytime + * regardless of the object's state. + */ +function run_test() +{ + DebuggerServer.addActors("resource://test/pre_init_global_actors.js"); + DebuggerServer.addActors("resource://test/pre_init_tab_actors.js"); + + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + + DebuggerServer.addActors("resource://test/post_init_global_actors.js"); + DebuggerServer.addActors("resource://test/post_init_tab_actors.js"); + + add_test(init); + add_test(test_pre_init_global_actor); + add_test(test_pre_init_tab_actor); + add_test(test_post_init_global_actor); + add_test(test_post_init_tab_actor); + add_test(test_stable_global_actor_instances); + add_test(close_client); + run_next_test(); +} + +function init() +{ + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect() + .then(() => gClient.listTabs()) + .then(aResponse => { + gActors = aResponse; + run_next_test(); + }); +} + +function test_pre_init_global_actor() +{ + gClient.request({ to: gActors.preInitGlobalActor, type: "ping" }, + function onResponse(aResponse) { + do_check_eq(aResponse.message, "pong"); + run_next_test(); + } + ); +} + +function test_pre_init_tab_actor() +{ + gClient.request({ to: gActors.preInitTabActor, type: "ping" }, + function onResponse(aResponse) { + do_check_eq(aResponse.message, "pong"); + run_next_test(); + } + ); +} + +function test_post_init_global_actor() +{ + gClient.request({ to: gActors.postInitGlobalActor, type: "ping" }, + function onResponse(aResponse) { + do_check_eq(aResponse.message, "pong"); + run_next_test(); + } + ); +} + +function test_post_init_tab_actor() +{ + gClient.request({ to: gActors.postInitTabActor, type: "ping" }, + function onResponse(aResponse) { + do_check_eq(aResponse.message, "pong"); + run_next_test(); + } + ); +} + +// Get the object object, from the server side, for a given actor ID +function getActorInstance(connID, actorID) { + return DebuggerServer._connections[connID].getActor(actorID); +} + +function test_stable_global_actor_instances() +{ + // Consider that there is only one connection, + // and the first one is ours + let connID = Object.keys(DebuggerServer._connections)[0]; + let postInitGlobalActor = getActorInstance(connID, gActors.postInitGlobalActor); + let preInitGlobalActor = getActorInstance(connID, gActors.preInitGlobalActor); + gClient.listTabs(function onListTabs(aResponse) { + do_check_eq(postInitGlobalActor, getActorInstance(connID, aResponse.postInitGlobalActor)); + do_check_eq(preInitGlobalActor, getActorInstance(connID, aResponse.preInitGlobalActor)); + run_next_test(); + }); +} + +function close_client() { + gClient.close().then(() => run_next_test()); +} diff --git a/devtools/server/tests/unit/test_addon_reload.js b/devtools/server/tests/unit/test_addon_reload.js new file mode 100644 index 000000000..0187fa3b3 --- /dev/null +++ b/devtools/server/tests/unit/test_addon_reload.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const protocol = require("devtools/shared/protocol"); +const {AddonManager} = require("resource://gre/modules/AddonManager.jsm"); + +startupAddonsManager(); + +function promiseAddonEvent(event) { + return new Promise(resolve => { + let listener = { + [event]: function (...args) { + AddonManager.removeAddonListener(listener); + resolve(args); + } + }; + + AddonManager.addAddonListener(listener); + }); +} + +function* findAddonInRootList(client, addonId) { + const result = yield client.listAddons(); + const addonActor = result.addons.filter(addon => addon.id === addonId)[0]; + ok(addonActor, `Found add-on actor for ${addonId}`); + return addonActor; +} + +function* reloadAddon(client, addonActor) { + // The add-on will be re-installed after a successful reload. + const onInstalled = promiseAddonEvent("onInstalled"); + yield client.request({to: addonActor.actor, type: "reload"}); + yield onInstalled; +} + +function getSupportFile(path) { + const allowMissing = false; + return do_get_file(path, allowMissing); +} + +add_task(function* testReloadExitedAddon() { + const client = yield new Promise(resolve => { + get_chrome_actors(client => resolve(client)); + }); + + // Install our main add-on to trigger reloads on. + const addonFile = getSupportFile("addons/web-extension"); + const installedAddon = yield AddonManager.installTemporaryAddon( + addonFile); + + // Install a decoy add-on. + const addonFile2 = getSupportFile("addons/web-extension2"); + const installedAddon2 = yield AddonManager.installTemporaryAddon( + addonFile2); + + let addonActor = yield findAddonInRootList(client, installedAddon.id); + + yield reloadAddon(client, addonActor); + + // Uninstall the decoy add-on, which should cause its actor to exit. + const onUninstalled = promiseAddonEvent("onUninstalled"); + installedAddon2.uninstall(); + const [uninstalledAddon] = yield onUninstalled; + + // Try to re-list all add-ons after a reload. + // This was throwing an exception because of the exited actor. + const newAddonActor = yield findAddonInRootList(client, installedAddon.id); + equal(newAddonActor.id, addonActor.id); + + // The actor id should be the same after the reload + equal(newAddonActor.actor, addonActor.actor); + + const onAddonListChanged = new Promise((resolve) => { + client.addListener("addonListChanged", function listener() { + client.removeListener("addonListChanged", listener); + resolve(); + }); + }); + + // Install an upgrade version of the first add-on. + const addonUpgradeFile = getSupportFile("addons/web-extension-upgrade"); + const upgradedAddon = yield AddonManager.installTemporaryAddon( + addonUpgradeFile); + + // Waiting for addonListChanged unsolicited event + yield onAddonListChanged; + + // re-list all add-ons after an upgrade. + const upgradedAddonActor = yield findAddonInRootList(client, upgradedAddon.id); + equal(upgradedAddonActor.id, addonActor.id); + // The actor id should be the same after the upgrade. + equal(upgradedAddonActor.actor, addonActor.actor); + + // The addon metadata has been updated. + equal(upgradedAddonActor.name, "Test Addons Actor Upgrade"); + + yield close(client); +}); diff --git a/devtools/server/tests/unit/test_addons_actor.js b/devtools/server/tests/unit/test_addons_actor.js new file mode 100644 index 000000000..1815d43c6 --- /dev/null +++ b/devtools/server/tests/unit/test_addons_actor.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const protocol = require("devtools/shared/protocol"); +const {AddonsActor} = require("devtools/server/actors/addons"); +const {AddonsFront} = require("devtools/shared/fronts/addons"); + +startupAddonsManager(); + +function* connect() { + const client = yield new Promise(resolve => { + get_chrome_actors(client => resolve(client)); + }); + const root = yield listTabs(client); + const addonsActor = root.addonsActor; + ok(addonsActor, "Got AddonsActor instance"); + + const addons = AddonsFront(client, {addonsActor}); + return [client, addons]; +} + +add_task(function* testSuccessfulInstall() { + const [client, addons] = yield connect(); + + const allowMissing = false; + const usePlatformSeparator = true; + const addonPath = getFilePath("addons/web-extension", + allowMissing, usePlatformSeparator); + const installedAddon = yield addons.installTemporaryAddon(addonPath); + equal(installedAddon.id, "test-addons-actor@mozilla.org"); + // The returned object is currently not a proper actor. + equal(installedAddon.actor, false); + + const addonList = yield client.listAddons(); + ok(addonList && addonList.addons && addonList.addons.map(a => a.name), + "Received list of add-ons"); + const addon = addonList.addons.filter(a => a.id === installedAddon.id)[0]; + ok(addon, "Test add-on appeared in root install list"); + + yield close(client); +}); + +add_task(function* testNonExistantPath() { + const [client, addons] = yield connect(); + + yield Assert.rejects( + addons.installTemporaryAddon("some-non-existant-path"), + /Could not install add-on.*Component returned failure/); + + yield close(client); +}); diff --git a/devtools/server/tests/unit/test_animation_name.js b/devtools/server/tests/unit/test_animation_name.js new file mode 100644 index 000000000..4cd708fc4 --- /dev/null +++ b/devtools/server/tests/unit/test_animation_name.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test that AnimationPlayerActor.getName returns the right name depending on
+// the type of an animation and the various properties available on it.
+
+const { AnimationPlayerActor } = require("devtools/server/actors/animation");
+
+function run_test() {
+ // Mock a window with just the properties the AnimationPlayerActor uses.
+ let window = {
+ MutationObserver: function () {
+ this.observe = () => {};
+ },
+ Animation: function () {
+ this.effect = {target: getMockNode()};
+ },
+ CSSAnimation: function () {
+ this.effect = {target: getMockNode()};
+ },
+ CSSTransition: function () {
+ this.effect = {target: getMockNode()};
+ }
+ };
+
+ window.CSSAnimation.prototype = Object.create(window.Animation.prototype);
+ window.CSSTransition.prototype = Object.create(window.Animation.prototype);
+
+ // Helper to get a mock DOM node.
+ function getMockNode() {
+ return {
+ ownerDocument: {
+ defaultView: window
+ }
+ };
+ }
+
+ // Objects in this array should contain the following properties:
+ // - desc {String} For logging
+ // - animation {Object} An animation object instantiated from one of the mock
+ // window animation constructors.
+ // - props {Objet} Properties of this object will be added to the animation
+ // object.
+ // - expectedName {String} The expected name returned by
+ // AnimationPlayerActor.getName.
+ const TEST_DATA = [{
+ desc: "Animation with an id",
+ animation: new window.Animation(),
+ props: { id: "animation-id" },
+ expectedName: "animation-id"
+ }, {
+ desc: "Animation without an id",
+ animation: new window.Animation(),
+ props: {},
+ expectedName: ""
+ }, {
+ desc: "CSSTransition with an id",
+ animation: new window.CSSTransition(),
+ props: { id: "transition-with-id", transitionProperty: "width" },
+ expectedName: "transition-with-id"
+ }, {
+ desc: "CSSAnimation with an id",
+ animation: new window.CSSAnimation(),
+ props: { id: "animation-with-id", animationName: "move" },
+ expectedName: "animation-with-id"
+ }, {
+ desc: "CSSTransition without an id",
+ animation: new window.CSSTransition(),
+ props: { transitionProperty: "width" },
+ expectedName: "width"
+ }, {
+ desc: "CSSAnimation without an id",
+ animation: new window.CSSAnimation(),
+ props: { animationName: "move" },
+ expectedName: "move"
+ }];
+
+ for (let { desc, animation, props, expectedName } of TEST_DATA) {
+ do_print(desc);
+ for (let key in props) {
+ animation[key] = props[key];
+ }
+ let actor = AnimationPlayerActor({}, animation);
+ do_check_eq(actor.getName(), expectedName);
+ }
+}
diff --git a/devtools/server/tests/unit/test_animation_type.js b/devtools/server/tests/unit/test_animation_type.js new file mode 100644 index 000000000..0f37755a4 --- /dev/null +++ b/devtools/server/tests/unit/test_animation_type.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test the output of AnimationPlayerActor.getType().
+
+const { ANIMATION_TYPES, AnimationPlayerActor } =
+ require("devtools/server/actors/animation");
+
+function run_test() {
+ // Mock a window with just the properties the AnimationPlayerActor uses.
+ let window = {
+ MutationObserver: function () {
+ this.observe = () => {};
+ },
+ Animation: function () {
+ this.effect = {target: getMockNode()};
+ },
+ CSSAnimation: function () {
+ this.effect = {target: getMockNode()};
+ },
+ CSSTransition: function () {
+ this.effect = {target: getMockNode()};
+ }
+ };
+
+ window.CSSAnimation.prototype = Object.create(window.Animation.prototype);
+ window.CSSTransition.prototype = Object.create(window.Animation.prototype);
+
+ // Helper to get a mock DOM node.
+ function getMockNode() {
+ return {
+ ownerDocument: {
+ defaultView: window
+ }
+ };
+ }
+
+ // Objects in this array should contain the following properties:
+ // - desc {String} For logging
+ // - animation {Object} An animation object instantiated from one of the mock
+ // window animation constructors.
+ // - expectedType {String} The expected type returned by
+ // AnimationPlayerActor.getType.
+ const TEST_DATA = [{
+ desc: "Test CSSAnimation type",
+ animation: new window.CSSAnimation(),
+ expectedType: ANIMATION_TYPES.CSS_ANIMATION
+ }, {
+ desc: "Test CSSTransition type",
+ animation: new window.CSSTransition(),
+ expectedType: ANIMATION_TYPES.CSS_TRANSITION
+ }, {
+ desc: "Test ScriptAnimation type",
+ animation: new window.Animation(),
+ expectedType: ANIMATION_TYPES.SCRIPT_ANIMATION
+ }, {
+ desc: "Test unknown type",
+ animation: {effect: {target: getMockNode()}},
+ expectedType: ANIMATION_TYPES.UNKNOWN
+ }];
+
+ for (let { desc, animation, expectedType } of TEST_DATA) {
+ do_print(desc);
+ let actor = AnimationPlayerActor({}, animation);
+ do_check_eq(actor.getType(), expectedType);
+ }
+}
diff --git a/devtools/server/tests/unit/test_attach.js b/devtools/server/tests/unit/test_attach.js new file mode 100644 index 000000000..a69db2c2b --- /dev/null +++ b/devtools/server/tests/unit/test_attach.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gClient; +var gDebuggee; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = testGlobal("test-1"); + DebuggerServer.addTestGlobal(gDebuggee); + + let transport = DebuggerServer.connectPipe(); + gClient = new DebuggerClient(transport); + gClient.connect().then(function ([aType, aTraits]) { + attachTestTab(gClient, "test-1", function (aReply, aTabClient) { + test_attach(aTabClient); + }); + }); + do_test_pending(); +} + +function test_attach(aTabClient) +{ + aTabClient.attachThread({}, function (aResponse, aThreadClient) { + do_check_eq(aThreadClient.state, "paused"); + aThreadClient.resume(cleanup); + }); +} + +function cleanup() +{ + gClient.addListener("closed", function (aEvent) { + do_test_finished(); + }); + gClient.close(); +} diff --git a/devtools/server/tests/unit/test_blackboxing-01.js b/devtools/server/tests/unit/test_blackboxing-01.js new file mode 100644 index 000000000..d5356c390 --- /dev/null +++ b/devtools/server/tests/unit/test_blackboxing-01.js @@ -0,0 +1,145 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test basic black boxing. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-black-box"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-black-box", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + testBlackBox(); + }); + }); + do_test_pending(); +} + +const BLACK_BOXED_URL = "http://example.com/blackboxme.js"; +const SOURCE_URL = "http://example.com/source.js"; + +const testBlackBox = Task.async(function* () { + let packet = yield executeOnNextTickAndWaitForPause(evalCode, gClient); + let source = gThreadClient.source(packet.frame.where.source); + + yield setBreakpoint(source, { + line: 2 + }); + yield resume(gThreadClient); + + const { sources } = yield getSources(gThreadClient); + let sourceClient = gThreadClient.source( + sources.filter(s => s.url == BLACK_BOXED_URL)[0]); + do_check_true(!sourceClient.isBlackBoxed, + "By default the source is not black boxed."); + + // Test that we can step into `doStuff` when we are not black boxed. + yield runTest( + function onSteppedLocation(aLocation) { + do_check_eq(aLocation.source.url, BLACK_BOXED_URL); + do_check_eq(aLocation.line, 2); + }, + function onDebuggerStatementFrames(aFrames) { + do_check_true(!aFrames.some(f => f.where.source.isBlackBoxed)); + } + ); + + let blackBoxResponse = yield blackBox(sourceClient); + do_check_true(sourceClient.isBlackBoxed); + + // Test that we step through `doStuff` when we are black boxed and its frame + // doesn't show up. + yield runTest( + function onSteppedLocation(aLocation) { + do_check_eq(aLocation.source.url, SOURCE_URL); + do_check_eq(aLocation.line, 4); + }, + function onDebuggerStatementFrames(aFrames) { + for (let f of aFrames) { + if (f.where.source.url == BLACK_BOXED_URL) { + do_check_true(f.where.source.isBlackBoxed); + } else { + do_check_true(!f.where.source.isBlackBoxed); + } + } + } + ); + + let unBlackBoxResponse = yield unBlackBox(sourceClient); + do_check_true(!sourceClient.isBlackBoxed); + + // Test that we can step into `doStuff` again. + yield runTest( + function onSteppedLocation(aLocation) { + do_check_eq(aLocation.source.url, BLACK_BOXED_URL); + do_check_eq(aLocation.line, 2); + }, + function onDebuggerStatementFrames(aFrames) { + do_check_true(!aFrames.some(f => f.where.source.isBlackBoxed)); + } + ); + + finishClient(gClient); +}); + +function evalCode() { + Components.utils.evalInSandbox( + "" + function doStuff(k) { // line 1 + let arg = 15; // line 2 - Step in here + k(arg); // line 3 + }, // line 4 + gDebuggee, + "1.8", + BLACK_BOXED_URL, + 1 + ); + + Components.utils.evalInSandbox( + "" + function runTest() { // line 1 + doStuff( // line 2 - Break here + function (n) { // line 3 - Step through `doStuff` to here + debugger; // line 4 + } // line 5 + ); // line 6 + } + "\n" // line 7 + + "debugger;", // line 8 + gDebuggee, + "1.8", + SOURCE_URL, + 1 + ); +} + +const runTest = Task.async(function* (onSteppedLocation, onDebuggerStatementFrames) { + let packet = yield executeOnNextTickAndWaitForPause(gDebuggee.runTest, + gClient); + do_check_eq(packet.why.type, "breakpoint"); + + yield stepIn(gClient, gThreadClient); + yield stepIn(gClient, gThreadClient); + yield stepIn(gClient, gThreadClient); + + const location = yield getCurrentLocation(); + onSteppedLocation(location); + + packet = yield resumeAndWaitForPause(gClient, gThreadClient); + do_check_eq(packet.why.type, "debuggerStatement"); + + let { frames } = yield getFrames(gThreadClient, 0, 100); + onDebuggerStatementFrames(frames); + + return resume(gThreadClient); +}); + +const getCurrentLocation = Task.async(function* () { + const response = yield getFrames(gThreadClient, 0, 1); + return response.frames[0].where; +}); diff --git a/devtools/server/tests/unit/test_blackboxing-02.js b/devtools/server/tests/unit/test_blackboxing-02.js new file mode 100644 index 000000000..5bfb3641a --- /dev/null +++ b/devtools/server/tests/unit/test_blackboxing-02.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we don't hit breakpoints in black boxed sources, and that when we + * unblack box the source again, the breakpoint hasn't disappeared and we will + * hit it again. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-black-box"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-black-box", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_black_box(); + }); + }); + do_test_pending(); +} + +const BLACK_BOXED_URL = "http://example.com/blackboxme.js"; +const SOURCE_URL = "http://example.com/source.js"; + +function test_black_box() +{ + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.eval(aPacket.frame.actor, "doStuff", function (aResponse) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let obj = gThreadClient.pauseGrip(aPacket.why.frameFinished.return); + obj.getDefinitionSite(runWithSource); + }); + }); + + function runWithSource(aPacket) { + let source = gThreadClient.source(aPacket.source); + source.setBreakpoint({ + line: 2 + }, function (aResponse) { + do_check_true(!aResponse.error, "Should be able to set breakpoint."); + gThreadClient.resume(test_black_box_breakpoint); + }); + } + }); + + Components.utils.evalInSandbox( + "" + function doStuff(k) { // line 1 + let arg = 15; // line 2 - Break here + k(arg); // line 3 + }, // line 4 + gDebuggee, + "1.8", + BLACK_BOXED_URL, + 1 + ); + + Components.utils.evalInSandbox( + "" + function runTest() { // line 1 + doStuff( // line 2 + function (n) { // line 3 + debugger; // line 5 + } // line 6 + ); // line 7 + } // line 8 + + "\n debugger;", // line 9 + gDebuggee, + "1.8", + SOURCE_URL, + 1 + ); +} + +function test_black_box_breakpoint() { + gThreadClient.getSources(function ({error, sources}) { + do_check_true(!error, "Should not get an error: " + error); + let sourceClient = gThreadClient.source(sources.filter(s => s.url == BLACK_BOXED_URL)[0]); + sourceClient.blackBox(function ({error}) { + do_check_true(!error, "Should not get an error: " + error); + + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aPacket.why.type, "debuggerStatement", + "We should pass over the breakpoint since the source is black boxed."); + gThreadClient.resume(test_unblack_box_breakpoint.bind(null, sourceClient)); + }); + gDebuggee.runTest(); + }); + }); +} + +function test_unblack_box_breakpoint(aSourceClient) { + aSourceClient.unblackBox(function ({error}) { + do_check_true(!error, "Should not get an error: " + error); + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aPacket.why.type, "breakpoint", + "We should hit the breakpoint again"); + + // We will hit the debugger statement on resume, so do this nastiness to skip over it. + gClient.addOneTimeListener( + "paused", + gThreadClient.resume.bind( + gThreadClient, + finishClient.bind(null, gClient))); + gThreadClient.resume(); + }); + gDebuggee.runTest(); + }); +} diff --git a/devtools/server/tests/unit/test_blackboxing-03.js b/devtools/server/tests/unit/test_blackboxing-03.js new file mode 100644 index 000000000..48f178777 --- /dev/null +++ b/devtools/server/tests/unit/test_blackboxing-03.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we don't stop at debugger statements inside black boxed sources. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gBpClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-black-box"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-black-box", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_black_box(); + }); + }); + do_test_pending(); +} + +const BLACK_BOXED_URL = "http://example.com/blackboxme.js"; +const SOURCE_URL = "http://example.com/source.js"; + +function test_black_box() +{ + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + source.setBreakpoint({ + line: 4 + }, function ({error}, bpClient) { + gBpClient = bpClient; + do_check_true(!error, "Should not get an error: " + error); + gThreadClient.resume(test_black_box_dbg_statement); + }); + }); + + Components.utils.evalInSandbox( + "" + function doStuff(k) { // line 1 + debugger; // line 2 - Break here + k(100); // line 3 + }, // line 4 + gDebuggee, + "1.8", + BLACK_BOXED_URL, + 1 + ); + + Components.utils.evalInSandbox( + "" + function runTest() { // line 1 + doStuff( // line 2 + function (n) { // line 3 + Math.abs(n); // line 4 - Break here + } // line 5 + ); // line 6 + } // line 7 + + "\n debugger;", // line 8 + gDebuggee, + "1.8", + SOURCE_URL, + 1 + ); +} + +function test_black_box_dbg_statement() { + gThreadClient.getSources(function ({error, sources}) { + do_check_true(!error, "Should not get an error: " + error); + let sourceClient = gThreadClient.source(sources.filter(s => s.url == BLACK_BOXED_URL)[0]); + + sourceClient.blackBox(function ({error}) { + do_check_true(!error, "Should not get an error: " + error); + + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aPacket.why.type, "breakpoint", + "We should pass over the debugger statement."); + gBpClient.remove(function ({error}) { + do_check_true(!error, "Should not get an error: " + error); + gThreadClient.resume(test_unblack_box_dbg_statement.bind(null, sourceClient)); + }); + }); + gDebuggee.runTest(); + }); + }); +} + +function test_unblack_box_dbg_statement(aSourceClient) { + aSourceClient.unblackBox(function ({error}) { + do_check_true(!error, "Should not get an error: " + error); + + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aPacket.why.type, "debuggerStatement", + "We should stop at the debugger statement again"); + finishClient(gClient); + }); + gDebuggee.runTest(); + }); +} diff --git a/devtools/server/tests/unit/test_blackboxing-04.js b/devtools/server/tests/unit/test_blackboxing-04.js new file mode 100644 index 000000000..fbfaf2881 --- /dev/null +++ b/devtools/server/tests/unit/test_blackboxing-04.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test behavior of blackboxing sources we are currently paused in. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-black-box"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-black-box", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_black_box(); + }); + }); + do_test_pending(); +} + +const BLACK_BOXED_URL = "http://example.com/blackboxme.js"; +const SOURCE_URL = "http://example.com/source.js"; + +function test_black_box() +{ + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.eval(aPacket.frame.actor, "doStuff", function (aResponse) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let obj = gThreadClient.pauseGrip(aPacket.why.frameFinished.return); + obj.getDefinitionSite(runWithSource); + }); + }); + + function runWithSource(aPacket) { + let source = gThreadClient.source(aPacket.source); + source.setBreakpoint({ + line: 2 + }, function (aResponse) { + do_check_true(!aResponse.error, "Should be able to set breakpoint."); + test_black_box_paused(); + }); + } + }); + + Components.utils.evalInSandbox( + "" + function doStuff(k) { // line 1 + debugger; // line 2 + k(100); // line 3 + }, // line 4 + gDebuggee, + "1.8", + BLACK_BOXED_URL, + 1 + ); + + Components.utils.evalInSandbox( + "" + function runTest() { // line 1 + doStuff( // line 2 + function (n) { // line 3 + return n; // line 4 + } // line 5 + ); // line 6 + } // line 7 + + "\n runTest();", // line 8 + gDebuggee, + "1.8", + SOURCE_URL, + 1 + ); +} + +function test_black_box_paused() { + gThreadClient.getSources(function ({error, sources}) { + do_check_true(!error, "Should not get an error: " + error); + let sourceClient = gThreadClient.source(sources.filter(s => s.url == BLACK_BOXED_URL)[0]); + + sourceClient.blackBox(function ({error, pausedInSource}) { + do_check_true(!error, "Should not get an error: " + error); + do_check_true(pausedInSource, "We should be notified that we are currently paused in this source"); + finishClient(gClient); + }); + }); +} diff --git a/devtools/server/tests/unit/test_blackboxing-05.js b/devtools/server/tests/unit/test_blackboxing-05.js new file mode 100644 index 000000000..fa8142e87 --- /dev/null +++ b/devtools/server/tests/unit/test_blackboxing-05.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test exceptions inside black boxed sources. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-black-box"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-black-box", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + // XXX: We have to do an executeSoon so that the error isn't caught and + // reported by DebuggerClient.requester (because we are using the local + // transport and share a stack) which causes the test to fail. + Services.tm.mainThread.dispatch({ + run: test_black_box + }, Ci.nsIThread.DISPATCH_NORMAL); + }); + }); + do_test_pending(); +} + +const BLACK_BOXED_URL = "http://example.com/blackboxme.js"; +const SOURCE_URL = "http://example.com/source.js"; + +function test_black_box() +{ + gClient.addOneTimeListener("paused", test_black_box_exception); + + Components.utils.evalInSandbox( + "" + function doStuff(k) { // line 1 + throw new Error("wu tang clan ain't nuthin' ta fuck wit"); // line 2 + k(100); // line 3 + }, // line 4 + gDebuggee, + "1.8", + BLACK_BOXED_URL, + 1 + ); + + Components.utils.evalInSandbox( + "" + function runTest() { // line 1 + doStuff( // line 2 + function (n) { // line 3 + debugger; // line 4 + } // line 5 + ); // line 6 + } // line 7 + + "\ndebugger;\n" // line 8 + + "try { runTest() } catch (ex) { }", // line 9 + gDebuggee, + "1.8", + SOURCE_URL, + 1 + ); +} + +function test_black_box_exception() { + gThreadClient.getSources(function ({error, sources}) { + do_check_true(!error, "Should not get an error: " + error); + let sourceClient = gThreadClient.source(sources.filter(s => s.url == BLACK_BOXED_URL)[0]); + + sourceClient.blackBox(function ({error}) { + do_check_true(!error, "Should not get an error: " + error); + gThreadClient.pauseOnExceptions(true); + + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aPacket.frame.where.source.url, SOURCE_URL, + "We shouldn't pause while in the black boxed source."); + finishClient(gClient); + }); + + gThreadClient.resume(); + }); + }); +} diff --git a/devtools/server/tests/unit/test_blackboxing-06.js b/devtools/server/tests/unit/test_blackboxing-06.js new file mode 100644 index 000000000..9384f2cc2 --- /dev/null +++ b/devtools/server/tests/unit/test_blackboxing-06.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we can black box source mapped sources. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +const {SourceNode} = require("source-map"); + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-black-box"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-black-box", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + + promise.resolve(setup_code()) + .then(black_box_code) + .then(run_code) + .then(test_correct_location) + .then(null, function (error) { + do_check_true(false, "Should not get an error, got " + error); + }) + .then(function () { + finishClient(gClient); + }); + }); + }); + do_test_pending(); +} + +function setup_code() { + let { code, map } = (new SourceNode(null, null, null, [ + new SourceNode(1, 0, "a.js", "" + function a() { + return b(); + }), + "\n", + new SourceNode(1, 0, "b.js", "" + function b() { + debugger; // Don't want to stop here. + return c(); + }), + "\n", + new SourceNode(1, 0, "c.js", "" + function c() { + debugger; // Want to stop here. + }), + "\n" + ])).toStringWithSourceMap({ + file: "abc.js", + sourceRoot: "http://example.com/" + }); + + code += "//# sourceMappingURL=data:text/json," + map.toString(); + + Components.utils.evalInSandbox(code, + gDebuggee, + "1.8", + "http://example.com/abc.js"); +} + +function black_box_code() { + const d = promise.defer(); + + gThreadClient.getSources(function ({ sources, error }) { + do_check_true(!error, "Shouldn't get an error getting sources"); + const source = sources.filter((s) => { + return s.url.indexOf("b.js") !== -1; + })[0]; + do_check_true(!!source, "We should have our source in the sources list"); + + gThreadClient.source(source).blackBox(function ({ error }) { + do_check_true(!error, "Should not get an error black boxing"); + d.resolve(true); + }); + }); + + return d.promise; +} + +function run_code() { + const d = promise.defer(); + + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + d.resolve(aPacket); + gThreadClient.resume(); + }); + gDebuggee.a(); + + return d.promise; +} + +function test_correct_location(aPacket) { + do_check_eq(aPacket.why.type, "debuggerStatement", + "Should hit a debugger statement."); + do_check_eq(aPacket.frame.where.source.url, "http://example.com/c.js", + "Should have skipped over the debugger statement in the black boxed source"); +} diff --git a/devtools/server/tests/unit/test_blackboxing-07.js b/devtools/server/tests/unit/test_blackboxing-07.js new file mode 100644 index 000000000..da3147021 --- /dev/null +++ b/devtools/server/tests/unit/test_blackboxing-07.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that sources whose URL ends with ".min.js" automatically get black + * boxed. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-black-box"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-black-box", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + testBlackBox(); + }); + }); + do_test_pending(); +} + +const BLACK_BOXED_URL = "http://example.com/black-boxed.min.js"; +const SOURCE_URL = "http://example.com/source.js"; + +const testBlackBox = Task.async(function* () { + yield executeOnNextTickAndWaitForPause(evalCode, gClient); + + const { sources } = yield getSources(gThreadClient); + equal(sources.length, 2); + + const blackBoxedSource = sources.filter(s => s.url === BLACK_BOXED_URL)[0]; + equal(blackBoxedSource.isBlackBoxed, true); + + const regularSource = sources.filter(s => s.url === SOURCE_URL)[0]; + equal(regularSource.isBlackBoxed, false); + + finishClient(gClient); +}); + +function evalCode() { + Components.utils.evalInSandbox( + "" + function blackBoxed() {}, + gDebuggee, + "1.8", + BLACK_BOXED_URL, + 1 + ); + + Components.utils.evalInSandbox( + "" + function source() {} + + "\ndebugger;", + gDebuggee, + "1.8", + SOURCE_URL, + 1 + ); +} diff --git a/devtools/server/tests/unit/test_breakpoint-01.js b/devtools/server/tests/unit/test_breakpoint-01.js new file mode 100644 index 000000000..9a20257a3 --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-01.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check basic breakpoint functionality. + */ +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_breakpoint(); + }); + }); +} + +function test_simple_breakpoint() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + let location = { + line: gDebuggee.line0 + 3 + }; + + source.setBreakpoint(location, function (aResponse, bpClient) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.source.actor, source.actor); + do_check_eq(aPacket.frame.where.line, location.line); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.actors[0], bpClient.actor); + // Check that the breakpoint worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, undefined); + + // Remove the breakpoint. + bpClient.remove(function (aResponse) { + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + + }); + + // Continue until the breakpoint is hit. + gThreadClient.resume(); + }); + }); + + Cu.evalInSandbox( + "var line0 = Error().lineNumber;\n" + + "debugger;\n" + // line0 + 1 + "var a = 1;\n" + // line0 + 2 + "var b = 2;\n", // line0 + 3 + gDebuggee + ); +} diff --git a/devtools/server/tests/unit/test_breakpoint-02.js b/devtools/server/tests/unit/test_breakpoint-02.js new file mode 100644 index 000000000..d2b220d83 --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-02.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that setting breakpoints when the debuggee is running works. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_breakpoint_running(); + }); + }); +} + +function test_breakpoint_running() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let location = { line: gDebuggee.line0 + 3 }; + + gThreadClient.resume(); + + // Setting the breakpoint later should interrupt the debuggee. + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.why.type, "interrupted"); + }); + + let source = gThreadClient.source(aPacket.frame.where.source); + source.setBreakpoint(location, function (aResponse) { + // Eval scripts don't stick around long enough for the breakpoint to be set, + // so just make sure we got the expected response from the actor. + do_check_neq(aResponse.error, "noScript"); + + do_execute_soon(function () { + gClient.close().then(gCallback); + }); + }); + }); + + Cu.evalInSandbox( + "var line0 = Error().lineNumber;\n" + + "debugger;\n" + + "var a = 1;\n" + // line0 + 2 + "var b = 2;\n", // line0 + 3 + gDebuggee + ); +} diff --git a/devtools/server/tests/unit/test_breakpoint-03.js b/devtools/server/tests/unit/test_breakpoint-03.js new file mode 100644 index 000000000..b1792866b --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-03.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that setting a breakpoint on a line without code will skip + * forward when we know the script isn't GCed (the debugger is connected, + * so it's kept alive). + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, + "test-stack", + function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_skip_breakpoint(); + }); + }); +} + +var test_no_skip_breakpoint = Task.async(function*(source, location) { + let [aResponse, bpClient] = yield source.setBreakpoint( + Object.assign({}, location, { noSliding: true }) + ); + + do_check_true(!aResponse.actualLocation); + do_check_eq(bpClient.location.line, gDebuggee.line0 + 3); + yield bpClient.remove(); +}); + +var test_skip_breakpoint = function() { + gThreadClient.addOneTimeListener("paused", Task.async(function *(aEvent, aPacket) { + let location = { line: gDebuggee.line0 + 3 }; + let source = gThreadClient.source(aPacket.frame.where.source); + + // First, make sure that we can disable sliding with the + // `noSliding` option. + yield test_no_skip_breakpoint(source, location); + + // Now make sure that the breakpoint properly slides forward one line. + const [aResponse, bpClient] = yield source.setBreakpoint(location); + do_check_true(!!aResponse.actualLocation); + do_check_eq(aResponse.actualLocation.source.actor, source.actor); + do_check_eq(aResponse.actualLocation.line, location.line + 1); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.source.actor, source.actor); + do_check_eq(aPacket.frame.where.line, location.line + 1); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.actors[0], bpClient.actor); + // Check that the breakpoint worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, undefined); + + // Remove the breakpoint. + bpClient.remove(function (aResponse) { + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + }); + + gThreadClient.resume(); + })); + + // Use `evalInSandbox` to make the debugger treat it as normal + // globally-scoped code, where breakpoint sliding rules apply. + Cu.evalInSandbox( + "var line0 = Error().lineNumber;\n" + + "debugger;\n" + // line0 + 1 + "var a = 1;\n" + // line0 + 2 + "// A comment.\n" + // line0 + 3 + "var b = 2;", // line0 + 4 + gDebuggee + ); +} diff --git a/devtools/server/tests/unit/test_breakpoint-04.js b/devtools/server/tests/unit/test_breakpoint-04.js new file mode 100644 index 000000000..9004c092b --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-04.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that setting a breakpoint in a line in a child script works. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_child_breakpoint(); + }); + }); +} + +function test_child_breakpoint() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + let location = { line: gDebuggee.line0 + 3 }; + + source.setBreakpoint(location, function (aResponse, bpClient) { + // actualLocation is not returned when breakpoints don't skip forward. + do_check_eq(aResponse.actualLocation, undefined); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.source.actor, source.actor); + do_check_eq(aPacket.frame.where.line, location.line); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.actors[0], bpClient.actor); + // Check that the breakpoint worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, undefined); + + // Remove the breakpoint. + bpClient.remove(function (aResponse) { + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + }); + + // Continue until the breakpoint is hit. + gThreadClient.resume(); + }); + + }); + + Cu.evalInSandbox( + "var line0 = Error().lineNumber;\n" + + "function foo() {\n" + // line0 + 1 + " this.a = 1;\n" + // line0 + 2 + " this.b = 2;\n" + // line0 + 3 + "}\n" + // line0 + 4 + "debugger;\n" + // line0 + 5 + "foo();\n", // line0 + 6 + gDebuggee + ); +} diff --git a/devtools/server/tests/unit/test_breakpoint-05.js b/devtools/server/tests/unit/test_breakpoint-05.js new file mode 100644 index 000000000..9e04d0271 --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-05.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that setting a breakpoint in a line without code in a child script + * will skip forward. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_child_skip_breakpoint(); + }); + }); +} + +function test_child_skip_breakpoint() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + let location = { line: gDebuggee.line0 + 3 }; + + source.setBreakpoint(location, function (aResponse, bpClient) { + // Check that the breakpoint has properly skipped forward one line. + do_check_eq(aResponse.actualLocation.source.actor, source.actor); + do_check_eq(aResponse.actualLocation.line, location.line + 1); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.source.actor, source.actor); + do_check_eq(aPacket.frame.where.line, location.line + 1); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.actors[0], bpClient.actor); + // Check that the breakpoint worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, undefined); + + // Remove the breakpoint. + bpClient.remove(function (aResponse) { + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + }); + + // Continue until the breakpoint is hit. + gThreadClient.resume(); + }); + }); + + Cu.evalInSandbox( + "var line0 = Error().lineNumber;\n" + + "function foo() {\n" + // line0 + 1 + " this.a = 1;\n" + // line0 + 2 + " // A comment.\n" + // line0 + 3 + " this.b = 2;\n" + // line0 + 4 + "}\n" + // line0 + 5 + "debugger;\n" + // line0 + 6 + "foo();\n", // line0 + 7 + gDebuggee + ); +} diff --git a/devtools/server/tests/unit/test_breakpoint-06.js b/devtools/server/tests/unit/test_breakpoint-06.js new file mode 100644 index 000000000..aa92b1a5f --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-06.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that setting a breakpoint in a line without code in a deeply-nested + * child script will skip forward. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_nested_breakpoint(); + }); + }); +} + +function test_nested_breakpoint() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + let location = { line: gDebuggee.line0 + 5 }; + + source.setBreakpoint(location, function (aResponse, bpClient) { + // Check that the breakpoint has properly skipped forward one line. + do_check_eq(aResponse.actualLocation.source.actor, source.actor); + do_check_eq(aResponse.actualLocation.line, location.line + 1); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.source.actor, source.actor); + do_check_eq(aPacket.frame.where.line, location.line + 1); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.actors[0], bpClient.actor); + // Check that the breakpoint worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, undefined); + + // Remove the breakpoint. + bpClient.remove(function (aResponse) { + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + }); + + // Continue until the breakpoint is hit. + gThreadClient.resume(); + }); + + }); + + Cu.evalInSandbox( + "var line0 = Error().lineNumber;\n" + + "function foo() {\n" + // line0 + 1 + " function bar() {\n" + // line0 + 2 + " function baz() {\n" + // line0 + 3 + " this.a = 1;\n" + // line0 + 4 + " // A comment.\n" + // line0 + 5 + " this.b = 2;\n" + // line0 + 6 + " }\n" + // line0 + 7 + " baz();\n" + // line0 + 8 + " }\n" + // line0 + 9 + " bar();\n" + // line0 + 10 + "}\n" + // line0 + 11 + "debugger;\n" + // line0 + 12 + "foo();\n", // line0 + 13 + gDebuggee + ); +} diff --git a/devtools/server/tests/unit/test_breakpoint-07.js b/devtools/server/tests/unit/test_breakpoint-07.js new file mode 100644 index 000000000..008f1424d --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-07.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that setting a breakpoint in a line without code in the second child + * script will skip forward. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_second_child_skip_breakpoint(); + }); + }); +} + +function test_second_child_skip_breakpoint() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + let location = { line: gDebuggee.line0 + 6 }; + + source.setBreakpoint(location, function (aResponse, bpClient) { + // Check that the breakpoint has properly skipped forward one line. + do_check_eq(aResponse.actualLocation.source.actor, source.actor); + do_check_eq(aResponse.actualLocation.line, location.line + 1); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.source.actor, source.actor); + do_check_eq(aPacket.frame.where.line, location.line + 1); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.actors[0], bpClient.actor); + // Check that the breakpoint worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, undefined); + + // Remove the breakpoint. + bpClient.remove(function (aResponse) { + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + }); + + // Continue until the breakpoint is hit. + gThreadClient.resume(); + }); + }); + + Cu.evalInSandbox( + "var line0 = Error().lineNumber;\n" + + "function foo() {\n" + // line0 + 1 + " bar();\n" + // line0 + 2 + "}\n" + // line0 + 3 + "function bar() {\n" + // line0 + 4 + " this.a = 1;\n" + // line0 + 5 + " // A comment.\n" + // line0 + 6 + " this.b = 2;\n" + // line0 + 7 + "}\n" + // line0 + 8 + "debugger;\n" + // line0 + 9 + "foo();\n", // line0 + 10 + gDebuggee + ); +} diff --git a/devtools/server/tests/unit/test_breakpoint-08.js b/devtools/server/tests/unit/test_breakpoint-08.js new file mode 100644 index 000000000..6215a61ac --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-08.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that setting a breakpoint in a line without code in a child script + * will skip forward, in a file with two scripts. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_child_skip_breakpoint(); + }); + }); +} + +function test_child_skip_breakpoint() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.eval(aPacket.frame.actor, "foo", function (aResponse) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let obj = gThreadClient.pauseGrip(aPacket.why.frameFinished.return); + obj.getDefinitionSite(runWithBreakpoint); + }); + }); + + function runWithBreakpoint(aPacket) { + let source = gThreadClient.source(aPacket.source); + let location = { line: gDebuggee.line0 + 3 }; + + source.setBreakpoint(location, function (aResponse, bpClient) { + // Check that the breakpoint has properly skipped forward one line. + do_check_eq(aResponse.actualLocation.source.actor, source.actor); + do_check_eq(aResponse.actualLocation.line, location.line + 1); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.source.actor, source.actor); + do_check_eq(aPacket.frame.where.line, location.line + 1); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.actors[0], bpClient.actor); + // Check that the breakpoint worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, undefined); + + // Remove the breakpoint. + bpClient.remove(function (aResponse) { + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + }); + + // Continue until the breakpoint is hit. + gThreadClient.resume(); + }); + } + }); + + Cu.evalInSandbox("var line0 = Error().lineNumber;\n" + + "function foo() {\n" + // line0 + 1 + " this.a = 1;\n" + // line0 + 2 + " // A comment.\n" + // line0 + 3 + " this.b = 2;\n" + // line0 + 4 + "}\n", // line0 + 5 + gDebuggee, + "1.7", + "script1.js"); + + Cu.evalInSandbox("var line1 = Error().lineNumber;\n" + + "debugger;\n" + // line1 + 1 + "foo();\n", // line1 + 2 + gDebuggee, + "1.7", + "script2.js"); +} diff --git a/devtools/server/tests/unit/test_breakpoint-09.js b/devtools/server/tests/unit/test_breakpoint-09.js new file mode 100644 index 000000000..8bea375b9 --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-09.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that removing a breakpoint works. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_remove_breakpoint(); + }); + }); +} + +function test_remove_breakpoint() +{ + let done = false; + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + let location = { line: gDebuggee.line0 + 2 }; + + source.setBreakpoint(location, function (aResponse, bpClient) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.source.actor, source.actor); + do_check_eq(aPacket.frame.where.line, location.line); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.actors[0], bpClient.actor); + // Check that the breakpoint worked. + do_check_eq(gDebuggee.a, undefined); + + // Remove the breakpoint. + bpClient.remove(function (aResponse) { + done = true; + gThreadClient.addOneTimeListener("paused", + function (aEvent, aPacket) { + // The breakpoint should not be hit again. + gThreadClient.resume(function () { + do_check_true(false); + }); + }); + gThreadClient.resume(); + }); + + }); + // Continue until the breakpoint is hit. + gThreadClient.resume(); + + }); + + }); + + Cu.evalInSandbox("var line0 = Error().lineNumber;\n" + + "function foo(stop) {\n" + // line0 + 1 + " this.a = 1;\n" + // line0 + 2 + " if (stop) return;\n" + // line0 + 3 + " delete this.a;\n" + // line0 + 4 + " foo(true);\n" + // line0 + 5 + "}\n" + // line0 + 6 + "debugger;\n" + // line1 + 7 + "foo();\n", // line1 + 8 + gDebuggee); + if (!done) { + do_check_true(false); + } + gClient.close().then(gCallback); +} diff --git a/devtools/server/tests/unit/test_breakpoint-10.js b/devtools/server/tests/unit/test_breakpoint-10.js new file mode 100644 index 000000000..c69576767 --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-10.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that setting a breakpoint in a line with multiple entry points + * triggers no matter which entry point we reach. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_child_breakpoint(); + }); + }); +} + +function test_child_breakpoint() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + let location = { line: gDebuggee.line0 + 3 }; + + source.setBreakpoint(location, function (aResponse, bpClient) { + // actualLocation is not returned when breakpoints don't skip forward. + do_check_eq(aResponse.actualLocation, undefined); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.actors[0], bpClient.actor); + // Check that the breakpoint worked. + do_check_eq(gDebuggee.i, 0); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.actors[0], bpClient.actor); + // Check that the breakpoint worked. + do_check_eq(gDebuggee.i, 1); + + // Remove the breakpoint. + bpClient.remove(function (aResponse) { + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + }); + + // Continue until the breakpoint is hit again. + gThreadClient.resume(); + + }); + // Continue until the breakpoint is hit. + gThreadClient.resume(); + + }); + + }); + + + Cu.evalInSandbox("var line0 = Error().lineNumber;\n" + + "debugger;\n" + // line0 + 1 + "var a, i = 0;\n" + // line0 + 2 + "for (i = 1; i <= 2; i++) {\n" + // line0 + 3 + " a = i;\n" + // line0 + 4 + "}\n", // line0 + 5 + gDebuggee); +} diff --git a/devtools/server/tests/unit/test_breakpoint-11.js b/devtools/server/tests/unit/test_breakpoint-11.js new file mode 100644 index 000000000..480b95984 --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-11.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Make sure that setting a breakpoint in a line with bytecodes in multiple + * scripts, sets the breakpoint in all of them (bug 793214). + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_child_breakpoint(); + }); + }); +} + +function test_child_breakpoint() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + let location = { line: gDebuggee.line0 + 2 }; + + source.setBreakpoint(location, function (aResponse, bpClient) { + // actualLocation is not returned when breakpoints don't skip forward. + do_check_eq(aResponse.actualLocation, undefined); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.actors[0], bpClient.actor); + // Check that the breakpoint worked. + do_check_eq(gDebuggee.a, undefined); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.actors[0], bpClient.actor); + // Check that the breakpoint worked. + do_check_eq(gDebuggee.a.b, 1); + do_check_eq(gDebuggee.res, undefined); + + // Remove the breakpoint. + bpClient.remove(function (aResponse) { + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + }); + + // Continue until the breakpoint is hit again. + gThreadClient.resume(); + + }); + // Continue until the breakpoint is hit. + gThreadClient.resume(); + + }); + + }); + + + Cu.evalInSandbox("var line0 = Error().lineNumber;\n" + + "debugger;\n" + // line0 + 1 + "var a = { b: 1, f: function() { return 2; } };\n" + // line0+2 + "var res = a.f();\n", // line0 + 3 + gDebuggee); +} diff --git a/devtools/server/tests/unit/test_breakpoint-12.js b/devtools/server/tests/unit/test_breakpoint-12.js new file mode 100644 index 000000000..fb147da9f --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-12.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Make sure that setting a breakpoint twice in a line without bytecodes works + * as expected. + */ + +const NUM_BREAKPOINTS = 10; +var gDebuggee; +var gClient; +var gThreadClient; +var gBpActor; +var gCount; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + gCount = 1; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_child_skip_breakpoint(); + }); + }); +} + +function test_child_skip_breakpoint() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + let location = { line: gDebuggee.line0 + 3}; + + source.setBreakpoint(location, function (aResponse, bpClient) { + // Check that the breakpoint has properly skipped forward one line. + do_check_eq(aResponse.actualLocation.source.actor, source.actor); + do_check_eq(aResponse.actualLocation.line, location.line + 1); + gBpActor = aResponse.actor; + + // Set more breakpoints at the same location. + set_breakpoints(source, location); + }); + + }); + + Cu.evalInSandbox("var line0 = Error().lineNumber;\n" + + "function foo() {\n" + // line0 + 1 + " this.a = 1;\n" + // line0 + 2 + " // A comment.\n" + // line0 + 3 + " this.b = 2;\n" + // line0 + 4 + "}\n" + // line0 + 5 + "debugger;\n" + // line0 + 6 + "foo();\n", // line0 + 7 + gDebuggee); +} + +// Set many breakpoints at the same location. +function set_breakpoints(source, location) { + do_check_neq(gCount, NUM_BREAKPOINTS); + source.setBreakpoint(location, function (aResponse, bpClient) { + // Check that the breakpoint has properly skipped forward one line. + do_check_eq(aResponse.actualLocation.source.actor, source.actor); + do_check_eq(aResponse.actualLocation.line, location.line + 1); + // Check that the same breakpoint actor was returned. + do_check_eq(aResponse.actor, gBpActor); + + if (++gCount < NUM_BREAKPOINTS) { + set_breakpoints(source, location); + return; + } + + // After setting all the breakpoints, check that only one has effectively + // remained. + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.source.actor, source.actor); + do_check_eq(aPacket.frame.where.line, location.line + 1); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.actors[0], bpClient.actor); + // Check that the breakpoint worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, undefined); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // We don't expect any more pauses after the breakpoint was hit once. + do_check_true(false); + }); + gThreadClient.resume(function () { + // Give any remaining breakpoints a chance to trigger. + do_timeout(1000, function () { + gClient.close().then(gCallback); + }); + }); + + }); + // Continue until the breakpoint is hit. + gThreadClient.resume(); + }); + +} diff --git a/devtools/server/tests/unit/test_breakpoint-13.js b/devtools/server/tests/unit/test_breakpoint-13.js new file mode 100644 index 000000000..cdc4c9091 --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-13.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that execution doesn't pause twice while stepping, when encountering + * either a breakpoint or a debugger statement. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_breakpoint(); + }); + }); +} + +function test_simple_breakpoint() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + let location = { line: gDebuggee.line0 + 2 }; + + source.setBreakpoint(location, Task.async(function* (aResponse, bpClient) { + const testCallbacks = [ + function (aPacket) { + // Check that the stepping worked. + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 5); + do_check_eq(aPacket.why.type, "resumeLimit"); + }, + function (aPacket) { + // Entered the foo function call frame. + do_check_eq(aPacket.frame.where.line, location.line); + do_check_neq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.type, "resumeLimit"); + }, + function (aPacket) { + // At the end of the foo function call frame. + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 3); + do_check_neq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.type, "resumeLimit"); + }, + function (aPacket) { + // Check that the breakpoint wasn't the reason for this pause, but + // that the frame is about to be popped while stepping. + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 3); + do_check_neq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.type, "resumeLimit"); + do_check_eq(aPacket.why.frameFinished.return.type, "undefined"); + }, + function (aPacket) { + // The foo function call frame was just popped from the stack. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, undefined); + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 5); + do_check_eq(aPacket.why.type, "resumeLimit"); + do_check_eq(aPacket.poppedFrames.length, 1); + }, + function (aPacket) { + // Check that the debugger statement wasn't the reason for this pause. + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 6); + do_check_neq(aPacket.why.type, "debuggerStatement"); + do_check_eq(aPacket.why.type, "resumeLimit"); + }, + function (aPacket) { + // Check that the debugger statement wasn't the reason for this pause. + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 7); + do_check_neq(aPacket.why.type, "debuggerStatement"); + do_check_eq(aPacket.why.type, "resumeLimit"); + }, + ]; + + for (let callback of testCallbacks) { + let waiter = waitForPause(gThreadClient); + gThreadClient.stepIn(); + let packet = yield waiter; + callback(packet); + } + + // Remove the breakpoint and finish. + let waiter = waitForPause(gThreadClient); + gThreadClient.stepIn(); + yield waiter; + bpClient.remove(() => gThreadClient.resume(() => gClient.close().then(gCallback))); + })); + }); + + Cu.evalInSandbox("var line0 = Error().lineNumber;\n" + + "function foo() {\n" + // line0 + 1 + " this.a = 1;\n" + // line0 + 2 <-- Breakpoint is set here. + "}\n" + // line0 + 3 + "debugger;\n" + // line0 + 4 + "foo();\n" + // line0 + 5 + "debugger;\n" + // line0 + 6 + "var b = 2;\n", // line0 + 7 + gDebuggee); +} diff --git a/devtools/server/tests/unit/test_breakpoint-14.js b/devtools/server/tests/unit/test_breakpoint-14.js new file mode 100644 index 000000000..aa86975b6 --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-14.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that a breakpoint or a debugger statement cause execution to pause even + * in a stepped-over function. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_breakpoint(); + }); + }); +} + +function test_simple_breakpoint() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + let location = { line: gDebuggee.line0 + 2 }; + + source.setBreakpoint(location, Task.async(function* (aResponse, bpClient) { + const testCallbacks = [ + function (aPacket) { + // Check that the stepping worked. + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 5); + do_check_eq(aPacket.why.type, "resumeLimit"); + }, + function (aPacket) { + // Reached the breakpoint. + do_check_eq(aPacket.frame.where.line, location.line); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_neq(aPacket.why.type, "resumeLimit"); + }, + function (aPacket) { + // Stepped to the closing brace of the function. + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 3); + do_check_eq(aPacket.why.type, "resumeLimit"); + }, + function (aPacket) { + // The frame is about to be popped while stepping. + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 3); + do_check_neq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.type, "resumeLimit"); + do_check_eq(aPacket.why.frameFinished.return.type, "undefined"); + }, + function (aPacket) { + // The foo function call frame was just popped from the stack. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, undefined); + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 5); + do_check_eq(aPacket.why.type, "resumeLimit"); + do_check_eq(aPacket.poppedFrames.length, 1); + }, + function (aPacket) { + // Check that the debugger statement wasn't the reason for this pause. + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 6); + do_check_neq(aPacket.why.type, "debuggerStatement"); + do_check_eq(aPacket.why.type, "resumeLimit"); + }, + function (aPacket) { + // Check that the debugger statement wasn't the reason for this pause. + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 7); + do_check_neq(aPacket.why.type, "debuggerStatement"); + do_check_eq(aPacket.why.type, "resumeLimit"); + }, + ]; + + for (let callback of testCallbacks) { + let waiter = waitForPause(gThreadClient); + gThreadClient.stepOver(); + let packet = yield waiter; + callback(packet); + } + + // Remove the breakpoint and finish. + let waiter = waitForPause(gThreadClient); + gThreadClient.stepOver(); + yield waiter; + bpClient.remove(() => gThreadClient.resume(() => gClient.close().then(gCallback))); + })); + }); + + Cu.evalInSandbox("var line0 = Error().lineNumber;\n" + + "function foo() {\n" + // line0 + 1 + " this.a = 1;\n" + // line0 + 2 <-- Breakpoint is set here. + "}\n" + // line0 + 3 + "debugger;\n" + // line0 + 4 + "foo();\n" + // line0 + 5 + "debugger;\n" + // line0 + 6 + "var b = 2;\n", // line0 + 7 + gDebuggee); +} diff --git a/devtools/server/tests/unit/test_breakpoint-15.js b/devtools/server/tests/unit/test_breakpoint-15.js new file mode 100644 index 000000000..6a3ab7c6f --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-15.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that adding a breakpoint in the same place returns the same actor. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + testSameBreakpoint(); + }); + }); + do_test_pending(); +} + +const SOURCE_URL = "http://example.com/source.js"; + +const testSameBreakpoint = Task.async(function* () { + let packet = yield executeOnNextTickAndWaitForPause(evalCode, gClient); + let source = gThreadClient.source(packet.frame.where.source); + + // Whole line + let wholeLineLocation = { + line: 2 + }; + + let [firstResponse, firstBpClient] = yield setBreakpoint(source, wholeLineLocation); + let [secondResponse, secondBpClient] = yield setBreakpoint(source, wholeLineLocation); + + do_check_eq(firstBpClient.actor, secondBpClient.actor, "Should get the same actor w/ whole line breakpoints"); + + // Specific column + + let columnLocation = { + line: 2, + column: 6 + }; + + [firstResponse, firstBpClient] = yield setBreakpoint(source, columnLocation); + [secondResponse, secondBpClient] = yield setBreakpoint(source, columnLocation); + + do_check_eq(secondBpClient.actor, secondBpClient.actor, "Should get the same actor column breakpoints"); + + finishClient(gClient); +}); + +function evalCode() { + Components.utils.evalInSandbox( + "" + function doStuff(k) { // line 1 + let arg = 15; // line 2 - Step in here + k(arg); // line 3 + } + "\n" // line 4 + + "debugger;", // line 5 + gDebuggee, + "1.8", + SOURCE_URL, + 1 + ); +} diff --git a/devtools/server/tests/unit/test_breakpoint-16.js b/devtools/server/tests/unit/test_breakpoint-16.js new file mode 100644 index 000000000..43a9086ec --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-16.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that we can set breakpoints in columns, not just lines. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-breakpoints", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, + "test-breakpoints", + function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_column_breakpoint(); + }); + }); +} + +function test_column_breakpoint() +{ + // Debugger statement + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + let location = { + line: gDebuggee.line0 + 1, + column: 55 + }; + let timesBreakpointHit = 0; + + source.setBreakpoint(location, function (aResponse, bpClient) { + gThreadClient.addListener("paused", function onPaused(aEvent, aPacket) { + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.why.actors[0], bpClient.actor); + do_check_eq(aPacket.frame.where.source.actor, source.actor); + do_check_eq(aPacket.frame.where.line, location.line); + do_check_eq(aPacket.frame.where.column, location.column); + + do_check_eq(gDebuggee.acc, timesBreakpointHit); + do_check_eq(aPacket.frame.environment.bindings.variables.i.value, + timesBreakpointHit); + + if (++timesBreakpointHit === 3) { + gThreadClient.removeListener("paused", onPaused); + bpClient.remove(function (aResponse) { + gThreadClient.resume(() => gClient.close().then(gCallback)); + }); + } else { + gThreadClient.resume(); + } + }); + + // Continue until the breakpoint is hit. + gThreadClient.resume(); + }); + + }); + + + Components.utils.evalInSandbox( + "var line0 = Error().lineNumber;\n" + + "(function () { debugger; this.acc = 0; for (var i = 0; i < 3; i++) this.acc++; }());", + gDebuggee + ); +} diff --git a/devtools/server/tests/unit/test_breakpoint-17.js b/devtools/server/tests/unit/test_breakpoint-17.js new file mode 100644 index 000000000..944627e61 --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-17.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that when we add 2 breakpoints to the same line at different columns and + * then remove one of them, we don't remove them both. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, do_test_finished); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-breakpoints", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-breakpoints", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_breakpoints_columns(); + }); + }); +} + +const code = +"(" + function (global) { + global.foo = function () { + Math.abs(-1); Math.log(0.5); + debugger; + }; + debugger; +} + "(this))"; + +const firstLocation = { + line: 3, + column: 4 +}; + +const secondLocation = { + line: 3, + column: 18 +}; + +function test_breakpoints_columns() { + gClient.addOneTimeListener("paused", set_breakpoints); + + Components.utils.evalInSandbox(code, gDebuggee, "1.8", "http://example.com/", 1); +} + +function set_breakpoints(aEvent, aPacket) { + let first, second; + let source = gThreadClient.source(aPacket.frame.where.source); + + source.setBreakpoint(firstLocation, function ({ error, actualLocation }, + aBreakpointClient) { + do_check_true(!error, "Should not get an error setting the breakpoint"); + do_check_true(!actualLocation, "Should not get an actualLocation"); + first = aBreakpointClient; + + source.setBreakpoint(secondLocation, function ({ error, actualLocation }, + aBreakpointClient) { + do_check_true(!error, "Should not get an error setting the breakpoint"); + do_check_true(!actualLocation, "Should not get an actualLocation"); + second = aBreakpointClient; + + test_different_actors(first, second); + }); + }); +} + +function test_different_actors(aFirst, aSecond) { + do_check_neq(aFirst.actor, aSecond.actor, + "Each breakpoint should have a different actor"); + test_remove_one(aFirst, aSecond); +} + +function test_remove_one(aFirst, aSecond) { + aFirst.remove(function ({error}) { + do_check_true(!error, "Should not get an error removing a breakpoint"); + + let hitSecond; + gClient.addListener("paused", function _onPaused(aEvent, {why, frame}) { + if (why.type == "breakpoint") { + hitSecond = true; + do_check_eq(why.actors.length, 1, + "Should only be paused because of one breakpoint actor"); + do_check_eq(why.actors[0], aSecond.actor, + "Should be paused because of the correct breakpoint actor"); + do_check_eq(frame.where.line, secondLocation.line, + "Should be at the right line"); + do_check_eq(frame.where.column, secondLocation.column, + "Should be at the right column"); + gThreadClient.resume(); + return; + } + + if (why.type == "debuggerStatement") { + gClient.removeListener("paused", _onPaused); + do_check_true(hitSecond, + "We should still hit `second`, but not `first`."); + + gClient.close().then(gCallback); + return; + } + + do_check_true(false, "Should never get here"); + }); + + gThreadClient.resume(() => gDebuggee.foo()); + }); +} diff --git a/devtools/server/tests/unit/test_breakpoint-18.js b/devtools/server/tests/unit/test_breakpoint-18.js new file mode 100644 index 000000000..d153d3eff --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-18.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that we only break on offsets that are entry points for the line we are + * breaking on. Bug 907278. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-breakpoints", aServer); + gDebuggee.console = { log: x => void x }; + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, + "test-breakpoints", + function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + setUpCode(); + }); + }); +} + +function setUpCode() { + gClient.addOneTimeListener("paused", setBreakpoint); + Cu.evalInSandbox( + "debugger;\n" + + function test() { + console.log("foo bar"); + debugger; + }, + gDebuggee, + "1.8", + "http://example.com/", + 1 + ); +} + +function setBreakpoint(aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + gClient.addOneTimeListener("resumed", runCode); + + source.setBreakpoint({ line: 2 }, ({ error }) => { + do_check_true(!error); + gThreadClient.resume(); + }); +} + +function runCode() { + gClient.addOneTimeListener("paused", testBPHit); + gDebuggee.test(); +} + +function testBPHit(event, { why }) { + do_check_eq(why.type, "breakpoint"); + gClient.addOneTimeListener("paused", testDbgStatement); + gThreadClient.resume(); +} + +function testDbgStatement(event, { why }) { + // Should continue to the debugger statement. + do_check_eq(why.type, "debuggerStatement"); + // Not break on another offset from the same line (that isn't an entry point + // to the line) + do_check_neq(why.type, "breakpoint"); + gClient.close().then(gCallback); +} diff --git a/devtools/server/tests/unit/test_breakpoint-19.js b/devtools/server/tests/unit/test_breakpoint-19.js new file mode 100644 index 000000000..da04a5268 --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-19.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Make sure that setting a breakpoint in a not-yet-existing script doesn't throw + * an error (see bug 897567). Also make sure that this breakpoint works. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-breakpoints", aServer); + gDebuggee.console = { log: x => void x }; + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, + "test-breakpoints", + function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + testBreakpoint(); + }); + }); +} + +const URL = "test.js"; + +function setUpCode() { + Cu.evalInSandbox( + "" + function test() { // 1 + var a = 1; // 2 + debugger; // 3 + } + // 4 + "\ndebugger;", // 5 + gDebuggee, + "1.8", + URL + ); +} + +const testBreakpoint = Task.async(function* () { + let source = yield getSource(gThreadClient, URL); + let [response, bpClient] = yield setBreakpoint(source, {line: 2}); + ok(!response.error); + + let actor = response.actor; + ok(actor); + + yield executeOnNextTickAndWaitForPause(setUpCode, gClient); + yield resume(gThreadClient); + + let packet = yield executeOnNextTickAndWaitForPause(gDebuggee.test, gClient); + equal(packet.why.type, "breakpoint"); + notEqual(packet.why.actors.indexOf(actor), -1); + + finishClient(gClient); +}); diff --git a/devtools/server/tests/unit/test_breakpoint-20.js b/devtools/server/tests/unit/test_breakpoint-20.js new file mode 100644 index 000000000..b70282dae --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-20.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Verify that when two of the "same" source are loaded concurrently (like e10s + * frame scripts), breakpoints get hit in scripts defined by all sources. + */ + +var gDebuggee; +var gClient; +var gTraceClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-breakpoints"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestThread(gClient, "test-breakpoints", testBreakpoint); + }); + do_test_pending(); +} + +const testBreakpoint = Task.async(function* (threadResponse, tabClient, threadClient, tabResponse) { + evalSetupCode(); + + // Load the test source once. + + evalTestCode(); + equal(gDebuggee.functions.length, 1, + "The test code should have added a function."); + + // Set a breakpoint in the test source. + + const source = yield getSource(threadClient, "test.js"); + const [response, bpClient] = yield setBreakpoint(source, { + line: 3 + }); + ok(!response.error, "Shouldn't get an error setting the BP."); + ok(!response.actualLocation, + "Shouldn't get an actualLocation, the location we provided was good."); + const bpActor = response.actor; + + yield resume(threadClient); + + // Load the test source again. + + evalTestCode(); + equal(gDebuggee.functions.length, 2, + "The test code should have added another function."); + + // Should hit our breakpoint in a script defined by the first instance of the + // test source. + + const bpPause1 = yield executeOnNextTickAndWaitForPause(gDebuggee.functions[0], + gClient); + equal(bpPause1.why.type, "breakpoint", + "Should pause because of hitting our breakpoint (not debugger statement)."); + equal(bpPause1.why.actors[0], bpActor, + "And the breakpoint actor should be correct."); + const dbgStmtPause1 = yield executeOnNextTickAndWaitForPause(() => resume(threadClient), + gClient); + equal(dbgStmtPause1.why.type, "debuggerStatement", + "And we should hit the debugger statement after the pause."); + yield resume(threadClient); + + // Should also hit our breakpoint in a script defined by the second instance + // of the test source. + + const bpPause2 = yield executeOnNextTickAndWaitForPause(gDebuggee.functions[1], + gClient); + equal(bpPause2.why.type, "breakpoint", + "Should pause because of hitting our breakpoint (not debugger statement)."); + equal(bpPause2.why.actors[0], bpActor, + "And the breakpoint actor should be correct."); + const dbgStmtPause2 = yield executeOnNextTickAndWaitForPause(() => resume(threadClient), + gClient); + equal(dbgStmtPause2.why.type, "debuggerStatement", + "And we should hit the debugger statement after the pause."); + + finishClient(gClient); +}); + +function evalSetupCode() { + Cu.evalInSandbox( + "this.functions = [];", + gDebuggee, + "1.8", + "setup.js", + 1 + ); +} + +function evalTestCode() { + Cu.evalInSandbox( + ` // 1 + this.functions.push(function () { // 2 + var setBreakpointHere = 1; // 3 + debugger; // 4 + }); // 5 + `, + gDebuggee, + "1.8", + "test.js", + 1 + ); +} diff --git a/devtools/server/tests/unit/test_breakpoint-21.js b/devtools/server/tests/unit/test_breakpoint-21.js new file mode 100644 index 000000000..e5f2e9e4a --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-21.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Bug 1122064 - make sure that scripts introduced via onNewScripts + * properly populate the `ScriptStore` with all there nested child + * scripts, so you can set breakpoints on deeply nested scripts + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-breakpoints", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, + "test-breakpoints", + function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test(); + }); + }); +} + +const test = Task.async(function* () { + // Populate the `ScriptStore` so that we only test that the script + // is added through `onNewScript` + yield getSources(gThreadClient); + + let packet = yield executeOnNextTickAndWaitForPause(evalCode, gClient); + let source = gThreadClient.source(packet.frame.where.source); + let location = { + line: gDebuggee.line0 + 8 + }; + + let [res, bpClient] = yield setBreakpoint(source, location); + ok(!res.error); + + yield resume(gThreadClient); + packet = yield waitForPause(gClient); + do_check_eq(packet.type, "paused"); + do_check_eq(packet.why.type, "breakpoint"); + do_check_eq(packet.why.actors[0], bpClient.actor); + do_check_eq(packet.frame.where.source.actor, source.actor); + do_check_eq(packet.frame.where.line, location.line); + + yield resume(gThreadClient); + finishClient(gClient); +}); + +function evalCode() { + // Start a new script + Components.utils.evalInSandbox( + "var line0 = Error().lineNumber;\n(" + function () { + debugger; + var a = (function () { + return (function () { + return (function () { + return (function () { + return (function () { + var x = 10; // This line gets a breakpoint + return 1; + })(); + })(); + })(); + })(); + })(); + } + ")()", + gDebuggee + ); +} diff --git a/devtools/server/tests/unit/test_breakpoint-actor-map.js b/devtools/server/tests/unit/test_breakpoint-actor-map.js new file mode 100644 index 000000000..d1d149648 --- /dev/null +++ b/devtools/server/tests/unit/test_breakpoint-actor-map.js @@ -0,0 +1,180 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the functionality of the BreakpointActorMap object. + +const { BreakpointActorMap } = require("devtools/server/actors/script"); + +function run_test() { + test_get_actor(); + test_set_actor(); + test_delete_actor(); + test_find_actors(); + test_duplicate_actors(); +} + +function test_get_actor() { + let bpStore = new BreakpointActorMap(); + let location = { + originalSourceActor: { actor: "actor1" }, + originalLine: 3 + }; + let columnLocation = { + originalSourceActor: { actor: "actor2" }, + originalLine: 5, + originalColumn: 15 + }; + + // Shouldn't have breakpoint + do_check_eq(null, bpStore.getActor(location), + "Breakpoint not added and shouldn't exist."); + + bpStore.setActor(location, {}); + do_check_true(!!bpStore.getActor(location), + "Breakpoint added but not found in Breakpoint Store."); + + bpStore.deleteActor(location); + do_check_eq(null, bpStore.getActor(location), + "Breakpoint removed but still exists."); + + // Same checks for breakpoint with a column + do_check_eq(null, bpStore.getActor(columnLocation), + "Breakpoint with column not added and shouldn't exist."); + + bpStore.setActor(columnLocation, {}); + do_check_true(!!bpStore.getActor(columnLocation), + "Breakpoint with column added but not found in Breakpoint Store."); + + bpStore.deleteActor(columnLocation); + do_check_eq(null, bpStore.getActor(columnLocation), + "Breakpoint with column removed but still exists in Breakpoint Store."); +} + +function test_set_actor() { + // Breakpoint with column + let bpStore = new BreakpointActorMap(); + let location = { + originalSourceActor: { actor: "actor1" }, + originalLine: 10, + originalColumn: 9 + }; + bpStore.setActor(location, {}); + do_check_true(!!bpStore.getActor(location), + "We should have the column breakpoint we just added"); + + // Breakpoint without column (whole line breakpoint) + location = { + originalSourceActor: { actor: "actor2" }, + originalLine: 103 + }; + bpStore.setActor(location, {}); + do_check_true(!!bpStore.getActor(location), + "We should have the whole line breakpoint we just added"); +} + +function test_delete_actor() { + // Breakpoint with column + let bpStore = new BreakpointActorMap(); + let location = { + originalSourceActor: { actor: "actor1" }, + originalLine: 10, + originalColumn: 9 + }; + bpStore.setActor(location, {}); + bpStore.deleteActor(location); + do_check_eq(bpStore.getActor(location), null, + "We should not have the column breakpoint anymore"); + + // Breakpoint without column (whole line breakpoint) + location = { + originalSourceActor: { actor: "actor2" }, + originalLine: 103 + }; + bpStore.setActor(location, {}); + bpStore.deleteActor(location); + do_check_eq(bpStore.getActor(location), null, + "We should not have the whole line breakpoint anymore"); +} + +function test_find_actors() { + let bps = [ + { originalSourceActor: { actor: "actor1" }, originalLine: 10 }, + { originalSourceActor: { actor: "actor1" }, originalLine: 10, originalColumn: 3 }, + { originalSourceActor: { actor: "actor1" }, originalLine: 10, originalColumn: 10 }, + { originalSourceActor: { actor: "actor1" }, originalLine: 23, originalColumn: 89 }, + { originalSourceActor: { actor: "actor2" }, originalLine: 10, originalColumn: 1 }, + { originalSourceActor: { actor: "actor2" }, originalLine: 20, originalColumn: 5 }, + { originalSourceActor: { actor: "actor2" }, originalLine: 30, originalColumn: 34 }, + { originalSourceActor: { actor: "actor2" }, originalLine: 40, originalColumn: 56 } + ]; + + let bpStore = new BreakpointActorMap(); + + for (let bp of bps) { + bpStore.setActor(bp, bp); + } + + // All breakpoints + + let bpSet = new Set(bps); + for (let bp of bpStore.findActors()) { + bpSet.delete(bp); + } + do_check_eq(bpSet.size, 0, + "Should be able to iterate over all breakpoints"); + + // Breakpoints by URL + + bpSet = new Set(bps.filter(bp => { return bp.originalSourceActor.actorID === "actor1"; })); + for (let bp of bpStore.findActors({ originalSourceActor: { actorID: "actor1" } })) { + bpSet.delete(bp); + } + do_check_eq(bpSet.size, 0, + "Should be able to filter the iteration by url"); + + // Breakpoints by URL and line + + bpSet = new Set(bps.filter(bp => { return bp.originalSourceActor.actorID === "actor1" && bp.originalLine === 10; })); + let first = true; + for (let bp of bpStore.findActors({ originalSourceActor: { actorID: "actor1" }, originalLine: 10 })) { + if (first) { + do_check_eq(bp.originalColumn, undefined, + "Should always get the whole line breakpoint first"); + first = false; + } else { + do_check_neq(bp.originalColumn, undefined, + "Should not get the whole line breakpoint any time other than first."); + } + bpSet.delete(bp); + } + do_check_eq(bpSet.size, 0, + "Should be able to filter the iteration by url and line"); +} + +function test_duplicate_actors() { + let bpStore = new BreakpointActorMap(); + + // Breakpoint with column + let location = { + originalSourceActor: { actorID: "foo-actor" }, + originalLine: 10, + originalColumn: 9 + }; + bpStore.setActor(location, {}); + bpStore.setActor(location, {}); + do_check_eq(bpStore.size, 1, "We should have only 1 column breakpoint"); + bpStore.deleteActor(location); + + // Breakpoint without column (whole line breakpoint) + location = { + originalSourceActor: { actorID: "foo-actor" }, + originalLine: 15 + }; + bpStore.setActor(location, {}); + bpStore.setActor(location, {}); + do_check_eq(bpStore.size, 1, "We should have only 1 whole line breakpoint"); + bpStore.deleteActor(location); +} diff --git a/devtools/server/tests/unit/test_client_close.js b/devtools/server/tests/unit/test_client_close.js new file mode 100644 index 000000000..84747e85b --- /dev/null +++ b/devtools/server/tests/unit/test_client_close.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gClient; +var gDebuggee; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = testGlobal("test-1"); + DebuggerServer.addTestGlobal(gDebuggee); + + let transport = DebuggerServer.connectPipe(); + gClient = new DebuggerClient(transport); + gClient.connect().then(function (aType, aTraits) { + attachTestTab(gClient, "test-1", function (aReply, aTabClient) { + test_close(transport); + }); + }); + do_test_pending(); +} + +function test_close(aTransport) +{ + // Check that, if we fake a transport shutdown + // (like if a device is unplugged) + // the client is automatically closed, + // and we can still call client.close. + let onClosed = function () { + gClient.removeListener("closed", onClosed); + ok(true, "Client emitted 'closed' event"); + gClient.close().then(function () { + ok(true, "client.close() successfully called its callback"); + do_test_finished(); + }); + }; + gClient.addListener("closed", onClosed); + aTransport.close(); +} diff --git a/devtools/server/tests/unit/test_client_request.js b/devtools/server/tests/unit/test_client_request.js new file mode 100644 index 000000000..c0c2c3a92 --- /dev/null +++ b/devtools/server/tests/unit/test_client_request.js @@ -0,0 +1,214 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test the DebuggerClient.request API. + +var gClient, gActorId; + +function TestActor(conn) { + this.conn = conn; +} +TestActor.prototype = { + actorPrefix: "test", + + hello: function () { + return {hello: "world"}; + }, + + error: function () { + return {error: "code", message: "human message"}; + } +}; +TestActor.prototype.requestTypes = { + "hello": TestActor.prototype.hello, + "error": TestActor.prototype.error +}; + +function run_test() +{ + DebuggerServer.addGlobalActor(TestActor); + + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + + add_test(init); + add_test(test_client_request_callback); + add_test(test_client_request_promise); + add_test(test_client_request_promise_error); + add_test(test_client_request_event_emitter); + add_test(test_close_client_while_sending_requests); + add_test(test_client_request_after_close); + add_test(test_client_request_after_close_callback); + run_next_test(); +} + +function init() +{ + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect() + .then(() => gClient.listTabs()) + .then(aResponse => { + gActorId = aResponse.test; + run_next_test(); + }); +} + +function checkStack(expectedName) { + if (!Services.prefs.getBoolPref("javascript.options.asyncstack")) { + do_print("Async stacks are disabled."); + return; + } + + let stack = Components.stack; + while (stack) { + do_print(stack.name); + if (stack.name == expectedName) { + // Reached back to outer function before request + ok(true, "Complete stack"); + return; + } + stack = stack.asyncCaller || stack.caller; + } + ok(false, "Incomplete stack"); +} + +function test_client_request_callback() +{ + // Test that DebuggerClient.request accepts a `onResponse` callback as 2nd argument + gClient.request({ + to: gActorId, + type: "hello" + }, response => { + do_check_eq(response.from, gActorId); + do_check_eq(response.hello, "world"); + checkStack("test_client_request_callback"); + run_next_test(); + }); +} + +function test_client_request_promise() +{ + // Test that DebuggerClient.request returns a promise that resolves on response + let request = gClient.request({ + to: gActorId, + type: "hello" + }); + + request.then(response => { + do_check_eq(response.from, gActorId); + do_check_eq(response.hello, "world"); + checkStack("test_client_request_promise"); + run_next_test(); + }); +} + +function test_client_request_promise_error() +{ + // Test that DebuggerClient.request returns a promise that reject when server + // returns an explicit error message + let request = gClient.request({ + to: gActorId, + type: "error" + }); + + request.then(() => { + do_throw("Promise shouldn't be resolved on error"); + }, response => { + do_check_eq(response.from, gActorId); + do_check_eq(response.error, "code"); + do_check_eq(response.message, "human message"); + checkStack("test_client_request_promise_error"); + run_next_test(); + }); +} + +function test_client_request_event_emitter() +{ + // Test that DebuggerClient.request returns also an EventEmitter object + let request = gClient.request({ + to: gActorId, + type: "hello" + }); + request.on("json-reply", reply => { + do_check_eq(reply.from, gActorId); + do_check_eq(reply.hello, "world"); + checkStack("test_client_request_event_emitter"); + run_next_test(); + }); +} + +function test_close_client_while_sending_requests() { + // First send a first request that will be "active" + // while the connection is closed. + // i.e. will be sent but no response received yet. + let activeRequest = gClient.request({ + to: gActorId, + type: "hello" + }); + + // Pile up a second one that will be "pending". + // i.e. won't event be sent. + let pendingRequest = gClient.request({ + to: gActorId, + type: "hello" + }); + + let expectReply = promise.defer(); + gClient.expectReply("root", function (response) { + do_check_eq(response.error, "connectionClosed"); + do_check_eq(response.message, "server side packet can't be received as the connection just closed."); + expectReply.resolve(); + }); + + gClient.close().then(() => { + activeRequest.then(() => { + ok(false, "First request unexpectedly succeed while closing the connection"); + }, response => { + do_check_eq(response.error, "connectionClosed"); + do_check_eq(response.message, "'hello' active request packet to '" + gActorId + "' can't be sent as the connection just closed."); + }) + .then(() => pendingRequest) + .then(() => { + ok(false, "Second request unexpectedly succeed while closing the connection"); + }, response => { + do_check_eq(response.error, "connectionClosed"); + do_check_eq(response.message, "'hello' pending request packet to '" + gActorId + "' can't be sent as the connection just closed."); + }) + .then(() => expectReply.promise) + .then(run_next_test); + }); +} + +function test_client_request_after_close() +{ + // Test that DebuggerClient.request fails after we called client.close() + // (with promise API) + let request = gClient.request({ + to: gActorId, + type: "hello" + }); + + request.then(response => { + ok(false, "Request succeed even after client.close"); + }, response => { + ok(true, "Request failed after client.close"); + do_check_eq(response.error, "connectionClosed"); + ok(response.message.match(/'hello' request packet to '.*' can't be sent as the connection is closed./)); + run_next_test(); + }); +} + +function test_client_request_after_close_callback() +{ + // Test that DebuggerClient.request fails after we called client.close() + // (with callback API) + let request = gClient.request({ + to: gActorId, + type: "hello" + }, response => { + ok(true, "Request failed after client.close"); + do_check_eq(response.error, "connectionClosed"); + ok(response.message.match(/'hello' request packet to '.*' can't be sent as the connection is closed./)); + run_next_test(); + }); +} diff --git a/devtools/server/tests/unit/test_conditional_breakpoint-01.js b/devtools/server/tests/unit/test_conditional_breakpoint-01.js new file mode 100644 index 000000000..4661bb0c4 --- /dev/null +++ b/devtools/server/tests/unit/test_conditional_breakpoint-01.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check conditional breakpoint when condition evaluates to true. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-conditional-breakpoint"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-conditional-breakpoint", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_breakpoint(); + }); + }); + do_test_pending(); +} + +function test_simple_breakpoint() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + source.setBreakpoint({ + line: 3, + condition: "a === 1" + }, function (aResponse, bpClient) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.why.type, "breakpoint"); + do_check_eq(aPacket.frame.where.line, 3); + + // Remove the breakpoint. + bpClient.remove(function (aResponse) { + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + + }); + // Continue until the breakpoint is hit. + gThreadClient.resume(); + + }); + + }); + + Components.utils.evalInSandbox("debugger;\n" + // 1 + "var a = 1;\n" + // 2 + "var b = 2;\n", // 3 + gDebuggee, + "1.8", + "test.js", + 1); +} diff --git a/devtools/server/tests/unit/test_conditional_breakpoint-02.js b/devtools/server/tests/unit/test_conditional_breakpoint-02.js new file mode 100644 index 000000000..873f76159 --- /dev/null +++ b/devtools/server/tests/unit/test_conditional_breakpoint-02.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check conditional breakpoint when condition evaluates to false. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-conditional-breakpoint"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-conditional-breakpoint", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_breakpoint(); + }); + }); + do_test_pending(); +} + +function test_simple_breakpoint() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + source.setBreakpoint({ + line: 3, + condition: "a === 2" + }, function (aResponse, bpClient) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.why.type, "debuggerStatement"); + do_check_eq(aPacket.frame.where.line, 4); + + // Remove the breakpoint. + bpClient.remove(function (aResponse) { + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + + }); + // Continue until the breakpoint is hit. + gThreadClient.resume(); + }); + }); + + Components.utils.evalInSandbox("debugger;\n" + // 1 + "var a = 1;\n" + // 2 + "var b = 2;\n" + // 3 + "debugger;", // 4 + gDebuggee, + "1.8", + "test.js", + 1); +} diff --git a/devtools/server/tests/unit/test_conditional_breakpoint-03.js b/devtools/server/tests/unit/test_conditional_breakpoint-03.js new file mode 100644 index 000000000..d9cf13e00 --- /dev/null +++ b/devtools/server/tests/unit/test_conditional_breakpoint-03.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check conditional breakpoint when condition throws and make sure it pauses + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-conditional-breakpoint"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-conditional-breakpoint", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_breakpoint(); + }); + }); + do_test_pending(); +} + +function test_simple_breakpoint() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let source = gThreadClient.source(aPacket.frame.where.source); + source.setBreakpoint({ + line: 3, + condition: "throw new Error()" + }, function (aResponse, bpClient) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.why.type, "breakpointConditionThrown"); + do_check_eq(aPacket.frame.where.line, 3); + + // Remove the breakpoint. + bpClient.remove(function (aResponse) { + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + + }); + // Continue until the breakpoint is hit. + gThreadClient.resume(); + + }); + + }); + + Components.utils.evalInSandbox("debugger;\n" + // 1 + "var a = 1;\n" + // 2 + "var b = 2;\n", // 3 + gDebuggee, + "1.8", + "test.js", + 1); +} diff --git a/devtools/server/tests/unit/test_dbgactor.js b/devtools/server/tests/unit/test_dbgactor.js new file mode 100644 index 000000000..b22b8446b --- /dev/null +++ b/devtools/server/tests/unit/test_dbgactor.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gClient; +var gDebuggee; + +const xpcInspector = Cc["@mozilla.org/jsinspector;1"].getService(Ci.nsIJSInspector); + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = testGlobal("test-1"); + DebuggerServer.addTestGlobal(gDebuggee); + + let transport = DebuggerServer.connectPipe(); + gClient = new DebuggerClient(transport); + gClient.addListener("connected", function (aEvent, aType, aTraits) { + gClient.listTabs((aResponse) => { + do_check_true("tabs" in aResponse); + for (let tab of aResponse.tabs) { + if (tab.title == "test-1") { + test_attach_tab(tab.actor); + return false; + } + } + do_check_true(false); // We should have found our tab in the list. + return undefined; + }); + }); + + gClient.connect(); + + do_test_pending(); +} + +// Attach to |aTabActor|, and check the response. +function test_attach_tab(aTabActor) +{ + gClient.request({ to: aTabActor, type: "attach" }, function (aResponse) { + do_check_false("error" in aResponse); + do_check_eq(aResponse.from, aTabActor); + do_check_eq(aResponse.type, "tabAttached"); + do_check_true(typeof aResponse.threadActor === "string"); + + test_attach_thread(aResponse.threadActor); + }); +} + +// Attach to |aThreadActor|, check the response, and resume it. +function test_attach_thread(aThreadActor) +{ + gClient.request({ to: aThreadActor, type: "attach" }, function (aResponse) { + do_check_false("error" in aResponse); + do_check_eq(aResponse.from, aThreadActor); + do_check_eq(aResponse.type, "paused"); + do_check_true("why" in aResponse); + do_check_eq(aResponse.why.type, "attached"); + + test_resume_thread(aThreadActor); + }); +} + +// Resume |aThreadActor|, and see that it stops at the 'debugger' +// statement. +function test_resume_thread(aThreadActor) +{ + // Allow the client to resume execution. + gClient.request({ to: aThreadActor, type: "resume" }, function (aResponse) { + do_check_false("error" in aResponse); + do_check_eq(aResponse.from, aThreadActor); + do_check_eq(aResponse.type, "resumed"); + + do_check_eq(xpcInspector.eventLoopNestLevel, 0); + + // Now that we know we're resumed, we can make the debuggee do something. + Cu.evalInSandbox("var a = true; var b = false; debugger; var b = true;", gDebuggee); + // Now make sure that we've run the code after the debugger statement... + do_check_true(gDebuggee.b); + }); + + gClient.addListener("paused", function (aName, aPacket) { + do_check_eq(aName, "paused"); + do_check_false("error" in aPacket); + do_check_eq(aPacket.from, aThreadActor); + do_check_eq(aPacket.type, "paused"); + do_check_true("actor" in aPacket); + do_check_true("why" in aPacket); + do_check_eq(aPacket.why.type, "debuggerStatement"); + + // Reach around the protocol to check that the debuggee is in the state + // we expect. + do_check_true(gDebuggee.a); + do_check_false(gDebuggee.b); + + do_check_eq(xpcInspector.eventLoopNestLevel, 1); + + // Let the debuggee continue execution. + gClient.request({ to: aThreadActor, type: "resume" }, cleanup); + }); +} + +function cleanup() +{ + gClient.addListener("closed", function (aEvent, aResult) { + do_test_finished(); + }); + + try { + let xpcInspector = Cc["@mozilla.org/jsinspector;1"].getService(Ci.nsIJSInspector); + do_check_eq(xpcInspector.eventLoopNestLevel, 0); + } catch (e) { + dump(e); + } + + gClient.close(); +} diff --git a/devtools/server/tests/unit/test_dbgclient_debuggerstatement.js b/devtools/server/tests/unit/test_dbgclient_debuggerstatement.js new file mode 100644 index 000000000..40468cb1d --- /dev/null +++ b/devtools/server/tests/unit/test_dbgclient_debuggerstatement.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gClient; +var gTabClient; +var gDebuggee; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = testGlobal("test-1"); + DebuggerServer.addTestGlobal(gDebuggee); + + let transport = DebuggerServer.connectPipe(); + gClient = new DebuggerClient(transport); + gClient.connect().then(function ([aType, aTraits]) { + attachTestTab(gClient, "test-1", function (aReply, aTabClient) { + gTabClient = aTabClient; + test_threadAttach(aReply.threadActor); + }); + }); + do_test_pending(); +} + +function test_threadAttach(aThreadActorID) +{ + do_print("Trying to attach to thread " + aThreadActorID); + gTabClient.attachThread({}, function (aResponse, aThreadClient) { + do_check_eq(aThreadClient.state, "paused"); + do_check_eq(aThreadClient.actor, aThreadActorID); + aThreadClient.resume(function () { + do_check_eq(aThreadClient.state, "attached"); + test_debugger_statement(aThreadClient); + }); + }); +} + +function test_debugger_statement(aThreadClient) +{ + aThreadClient.addListener("paused", function (aEvent, aPacket) { + do_check_eq(aThreadClient.state, "paused"); + // Reach around the protocol to check that the debuggee is in the state + // we expect. + do_check_true(gDebuggee.a); + do_check_false(gDebuggee.b); + + let xpcInspector = Cc["@mozilla.org/jsinspector;1"].getService(Ci.nsIJSInspector); + do_check_eq(xpcInspector.eventLoopNestLevel, 1); + + aThreadClient.resume(cleanup); + }); + + Cu.evalInSandbox("var a = true; var b = false; debugger; var b = true;", gDebuggee); + + // Now make sure that we've run the code after the debugger statement... + do_check_true(gDebuggee.b); +} + +function cleanup() +{ + gClient.addListener("closed", function (aEvent) { + do_test_finished(); + }); + + try { + let xpcInspector = Cc["@mozilla.org/jsinspector;1"].getService(Ci.nsIJSInspector); + do_check_eq(xpcInspector.eventLoopNestLevel, 0); + } catch (e) { + dump(e); + } + + gClient.close(); +} diff --git a/devtools/server/tests/unit/test_dbgglobal.js b/devtools/server/tests/unit/test_dbgglobal.js new file mode 100644 index 000000000..ff4291932 --- /dev/null +++ b/devtools/server/tests/unit/test_dbgglobal.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function run_test() +{ + // Should get an exception if we try to interact with DebuggerServer + // before we initialize it... + check_except(function () { + DebuggerServer.createListener(); + }); + check_except(DebuggerServer.closeAllListeners); + check_except(DebuggerServer.connectPipe); + + // Allow incoming connections. + DebuggerServer.init(); + + // These should still fail because we haven't added a createRootActor + // implementation yet. + check_except(function () { + DebuggerServer.createListener(); + }); + check_except(DebuggerServer.closeAllListeners); + check_except(DebuggerServer.connectPipe); + + DebuggerServer.registerModule("xpcshell-test/testactors"); + + // Now they should work. + DebuggerServer.createListener(); + DebuggerServer.closeAllListeners(); + + // Make sure we got the test's root actor all set up. + let client1 = DebuggerServer.connectPipe(); + client1.hooks = { + onPacket: function (aPacket1) { + do_check_eq(aPacket1.from, "root"); + do_check_eq(aPacket1.applicationType, "xpcshell-tests"); + + // Spin up a second connection, make sure it has its own root + // actor. + let client2 = DebuggerServer.connectPipe(); + client2.hooks = { + onPacket: function (aPacket2) { + do_check_eq(aPacket2.from, "root"); + do_check_neq(aPacket1.testConnectionPrefix, + aPacket2.testConnectionPrefix); + client2.close(); + }, + onClosed: function (aResult) { + client1.close(); + }, + }; + client2.ready(); + }, + + onClosed: function (aResult) { + do_test_finished(); + }, + }; + + client1.ready(); + do_test_pending(); +} diff --git a/devtools/server/tests/unit/test_eval-01.js b/devtools/server/tests/unit/test_eval-01.js new file mode 100644 index 000000000..b11903f87 --- /dev/null +++ b/devtools/server/tests/unit/test_eval-01.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check basic eval resume/re-pause + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_eval(); + }); + }); + do_test_pending(); +} + +function test_simple_eval() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let arg1Actor = aPacket.frame.arguments[0].actor; + gThreadClient.eval(null, "({ obj: true })", function (aResponse) { + do_check_eq(aResponse.type, "resumed"); + // Expect a pause notification immediately. + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value... + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.why.type, "clientEvaluated"); + do_check_eq(aPacket.why.frameFinished.return.type, "object"); + do_check_eq(aPacket.why.frameFinished.return.class, "Object"); + + // Make sure the previous pause lifetime was correctly dropped. + gClient.request({ to: arg1Actor, type: "bogusRequest" }, function (aResponse) { + do_check_eq(aResponse.error, "noSuchActor"); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + + }); + + }); + + }); + + gDebuggee.eval("(" + function () { + function stopMe(arg1) { debugger; } + stopMe({obj: true}); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_eval-02.js b/devtools/server/tests/unit/test_eval-02.js new file mode 100644 index 000000000..386ea5c98 --- /dev/null +++ b/devtools/server/tests/unit/test_eval-02.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check eval resume/re-pause with a throw. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_throw_eval(); + }); + }); + do_test_pending(); +} + +function test_throw_eval() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.eval(null, "throw 'failure'", function (aResponse) { + do_check_eq(aResponse.type, "resumed"); + // Expect a pause notification immediately. + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value... + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.why.type, "clientEvaluated"); + do_check_eq(aPacket.why.frameFinished.throw, "failure"); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe(arg1) { debugger; } + stopMe({obj: true}); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_eval-03.js b/devtools/server/tests/unit/test_eval-03.js new file mode 100644 index 000000000..2234259aa --- /dev/null +++ b/devtools/server/tests/unit/test_eval-03.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check syntax errors in an eval. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_syntax_error_eval(); + }); + }); + do_test_pending(); +} + +function test_syntax_error_eval() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.eval(null, "%$@!@#", function (aResponse) { + do_check_eq(aResponse.type, "resumed"); + // Expect a pause notification immediately. + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value... + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.why.type, "clientEvaluated"); + do_check_eq(aPacket.why.frameFinished.throw.type, "object"); + do_check_eq(aPacket.why.frameFinished.throw.class, "Error"); + + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe(arg1) { debugger; } + stopMe({obj: true}); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_eval-04.js b/devtools/server/tests/unit/test_eval-04.js new file mode 100644 index 000000000..77cb58d97 --- /dev/null +++ b/devtools/server/tests/unit/test_eval-04.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check evals against different frames. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_syntax_error_eval(); + }); + }); + do_test_pending(); +} + +function test_syntax_error_eval() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + + gThreadClient.getFrames(0, 2, function (aResponse) { + let frame0 = aResponse.frames[0]; + let frame1 = aResponse.frames[1]; + + // Eval against the top frame... + gThreadClient.eval(frame0.actor, "arg", function (aResponse) { + do_check_eq(aResponse.type, "resumed"); + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // 'arg' should have been evaluated in frame0 + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.why.type, "clientEvaluated"); + do_check_eq(aPacket.why.frameFinished.return, "arg0"); + + // Now eval against the second frame. + gThreadClient.eval(frame1.actor, "arg", function (aResponse) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // 'arg' should have been evaluated in frame1 + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.why.frameFinished.return, "arg1"); + + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + }); + }); + }); + }); + + gDebuggee.eval("(" + function () { + function frame0(arg) { + debugger; + } + function frame1(arg) { + frame0("arg0"); + } + frame1("arg1"); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_eval-05.js b/devtools/server/tests/unit/test_eval-05.js new file mode 100644 index 000000000..b199e4afb --- /dev/null +++ b/devtools/server/tests/unit/test_eval-05.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check pauses within evals. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_syntax_error_eval(); + }); + }); + do_test_pending(); +} + +function test_syntax_error_eval() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.eval(null, "debugger", function (aResponse) { + // Expect a resume then a debuggerStatement pause. + do_check_eq(aResponse.type, "resumed"); + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aPacket.why.type, "debuggerStatement"); + // Resume from the debugger statement should immediately re-pause + // with a clientEvaluated reason. + gThreadClient.resume(function (aPacket) { + do_check_eq(aPacket.type, "resumed"); + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aPacket.why.type, "clientEvaluated"); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + }); + }); + }); + gDebuggee.eval("(" + function () { + function stopMe(arg) { + debugger; + } + stopMe(); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_eventlooplag_actor.js b/devtools/server/tests/unit/test_eventlooplag_actor.js new file mode 100644 index 000000000..d2acdd8f8 --- /dev/null +++ b/devtools/server/tests/unit/test_eventlooplag_actor.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test the eventLoopLag actor. + */ + +"use strict"; + +function run_test() +{ + let {EventLoopLagFront} = require("devtools/shared/fronts/eventlooplag"); + + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + + // As seen in EventTracer.cpp + let threshold = 20; + let interval = 10; + + + let front; + let client = new DebuggerClient(DebuggerServer.connectPipe()); + + // Start tracking event loop lags. + client.connect().then(function () { + client.listTabs(function (resp) { + front = new EventLoopLagFront(client, resp); + front.start().then(success => { + do_check_true(success); + front.once("event-loop-lag", gotLagEvent); + do_execute_soon(lag); + }); + }); + }); + + // Force a lag + function lag() { + let start = new Date(); + let duration = threshold + interval + 1; + while (true) { + if (((new Date()) - start) > duration) { + break; + } + } + } + + // Got a lag event. The test will time out if the actor + // fails to detect the lag. + function gotLagEvent(time) { + do_print("lag: " + time); + do_check_true(time >= threshold); + front.stop().then(() => { + finishClient(client); + }); + } + + do_test_pending(); +} diff --git a/devtools/server/tests/unit/test_forwardingprefix.js b/devtools/server/tests/unit/test_forwardingprefix.js new file mode 100644 index 000000000..885a99db8 --- /dev/null +++ b/devtools/server/tests/unit/test_forwardingprefix.js @@ -0,0 +1,196 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* Exercise prefix-based forwarding of packets to other transports. */ + +const { RootActor } = require("devtools/server/actors/root"); + +var gMainConnection, gMainTransport; +var gSubconnection1, gSubconnection2; +var gClient; + +function run_test() +{ + DebuggerServer.init(); + + add_test(createMainConnection); + add_test(TestNoForwardingYet); + add_test(createSubconnection1); + add_test(TestForwardPrefix1OnlyRoot); + add_test(createSubconnection2); + add_test(TestForwardPrefix12OnlyRoot); + add_test(TestForwardPrefix12WithActor1); + add_test(TestForwardPrefix12WithActor12); + run_next_test(); +} + +/* + * Create a pipe connection, and return an object |{ conn, transport }|, + * where |conn| is the new DebuggerServerConnection instance, and + * |transport| is the client side of the transport on which it communicates + * (that is, packets sent on |transport| go to the new connection, and + * |transport|'s hooks receive replies). + * + * |aPrefix| is optional; if present, it's the prefix (minus the '/') for + * actors in the new connection. + */ +function newConnection(aPrefix) +{ + var conn; + DebuggerServer.createRootActor = function (aConn) { + conn = aConn; + return new RootActor(aConn, {}); + }; + + var transport = DebuggerServer.connectPipe(aPrefix); + + return { conn: conn, transport: transport }; +} + +/* Create the main connection for these tests. */ +function createMainConnection() +{ + ({ conn: gMainConnection, transport: gMainTransport } = newConnection()); + gClient = new DebuggerClient(gMainTransport); + gClient.connect().then(([aType, aTraits]) => run_next_test()); +} + +/* + * Exchange 'echo' messages with five actors: + * - root + * - prefix1/root + * - prefix1/actor + * - prefix2/root + * - prefix2/actor + * + * Expect proper echos from those named in |aReachables|, and 'noSuchActor' + * errors from the others. When we've gotten all our replies (errors or + * otherwise), call |aCompleted|. + * + * To avoid deep stacks, we call aCompleted from the next tick. + */ +function tryActors(aReachables, aCompleted) { + let count = 0; + + let outerActor; + for (outerActor of [ "root", + "prefix1/root", "prefix1/actor", + "prefix2/root", "prefix2/actor" ]) { + /* + * Let each callback capture its own iteration's value; outerActor is + * local to the whole loop, not to a single iteration. + */ + let actor = outerActor; + + count++; + + gClient.request({ to: actor, type: "echo", value: "tango"}, // phone home + (aResponse) => { + if (aReachables.has(actor)) + do_check_matches({ from: actor, to: actor, type: "echo", value: "tango" }, aResponse); + else + do_check_matches({ from: actor, error: "noSuchActor", message: "No such actor for ID: " + actor }, aResponse); + + if (--count == 0) + do_execute_soon(aCompleted, "tryActors callback " + aCompleted.name); + }); + } +} + +/* + * With no forwarding established, sending messages to root should work, + * but sending messages to prefixed actor names, or anyone else, should get + * an error. + */ +function TestNoForwardingYet() +{ + tryActors(new Set(["root"]), run_next_test); +} + +/* + * Create a new pipe connection which forwards its reply packets to + * gMainConnection's client, and to which gMainConnection forwards packets + * directed to actors whose names begin with |aPrefix + '/'|, and. + * + * Return an object { conn, transport }, as for newConnection. + */ +function newSubconnection(aPrefix) +{ + let { conn, transport } = newConnection(aPrefix); + transport.hooks = { + onPacket: (aPacket) => gMainConnection.send(aPacket), + onClosed: () => {} + }; + gMainConnection.setForwarding(aPrefix, transport); + + return { conn: conn, transport: transport }; +} + +/* Create a second root actor, to which we can forward things. */ +function createSubconnection1() +{ + let { conn, transport } = newSubconnection("prefix1"); + gSubconnection1 = conn; + transport.ready(); + gClient.expectReply("prefix1/root", (aReply) => run_next_test()); +} + +// Establish forwarding, but don't put any actors in that server. +function TestForwardPrefix1OnlyRoot() +{ + tryActors(new Set(["root", "prefix1/root"]), run_next_test); +} + +/* Create a third root actor, to which we can forward things. */ +function createSubconnection2() +{ + let { conn, transport } = newSubconnection("prefix2"); + gSubconnection2 = conn; + transport.ready(); + gClient.expectReply("prefix2/root", (aReply) => run_next_test()); +} + +function TestForwardPrefix12OnlyRoot() +{ + tryActors(new Set(["root", "prefix1/root", "prefix2/root"]), run_next_test); +} + +// A dumb actor that implements 'echo'. +// +// It's okay that both subconnections' actors behave identically, because +// the reply-sending code attaches the replying actor's name to the packet, +// so simply matching the 'from' field in the reply ensures that we heard +// from the right actor. +function EchoActor(aConnection) +{ + this.conn = aConnection; +} +EchoActor.prototype.actorPrefix = "EchoActor"; +EchoActor.prototype.onEcho = function (aRequest) { + /* + * Request packets are frozen. Copy aRequest, so that + * DebuggerServerConnection.onPacket can attach a 'from' property. + */ + return JSON.parse(JSON.stringify(aRequest)); +}; +EchoActor.prototype.requestTypes = { + "echo": EchoActor.prototype.onEcho +}; + +function TestForwardPrefix12WithActor1() +{ + let actor = new EchoActor(gSubconnection1); + actor.actorID = "prefix1/actor"; + gSubconnection1.addActor(actor); + + tryActors(new Set(["root", "prefix1/root", "prefix1/actor", "prefix2/root"]), run_next_test); +} + +function TestForwardPrefix12WithActor12() +{ + let actor = new EchoActor(gSubconnection2); + actor.actorID = "prefix2/actor"; + gSubconnection2.addActor(actor); + + tryActors(new Set(["root", "prefix1/root", "prefix1/actor", "prefix2/root", "prefix2/actor"]), run_next_test); +} diff --git a/devtools/server/tests/unit/test_frameactor-01.js b/devtools/server/tests/unit/test_frameactor-01.js new file mode 100644 index 000000000..ad37a8ab5 --- /dev/null +++ b/devtools/server/tests/unit/test_frameactor-01.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Verify that we get a frame actor along with a debugger statement. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_true(!!aPacket.frame); + do_check_true(!!aPacket.frame.actor); + do_check_eq(aPacket.frame.callee.name, "stopMe"); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe() { + debugger; + } + stopMe(); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_frameactor-02.js b/devtools/server/tests/unit/test_frameactor-02.js new file mode 100644 index 000000000..f2890adac --- /dev/null +++ b/devtools/server/tests/unit/test_frameactor-02.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Verify that two pauses in a row will keep the same frame actor. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket1) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket2) { + do_check_eq(aPacket1.frame.actor, aPacket2.frame.actor); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + gThreadClient.resume(); + }); + + gDebuggee.eval("(" + function () { + function stopMe() { + debugger; + debugger; + } + stopMe(); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_frameactor-03.js b/devtools/server/tests/unit/test_frameactor-03.js new file mode 100644 index 000000000..0d7739d5a --- /dev/null +++ b/devtools/server/tests/unit/test_frameactor-03.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Verify that a frame actor is properly expired when the frame goes away. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket1) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket2) { + let poppedFrames = aPacket2.poppedFrames; + do_check_eq(typeof (poppedFrames), typeof ([])); + do_check_true(poppedFrames.indexOf(aPacket1.frame.actor) >= 0); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + gThreadClient.resume(); + }); + + gDebuggee.eval("(" + function () { + function stopMe() { + debugger; + } + stopMe(); + debugger; + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_frameactor-04.js b/devtools/server/tests/unit/test_frameactor-04.js new file mode 100644 index 000000000..b4faa96e0 --- /dev/null +++ b/devtools/server/tests/unit/test_frameactor-04.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Verify the "frames" request on the thread. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +var gFrames = [ + // Function calls... + { type: "call", callee: { name: "depth3" } }, + { type: "call", callee: { name: "depth2" } }, + { type: "call", callee: { name: "depth1" } }, + + // Anonymous function call in our eval... + { type: "call", callee: { name: undefined } }, + + // The eval itself. + { type: "eval", callee: { name: undefined } }, +]; + +var gSliceTests = [ + { start: 0, count: undefined, resetActors: true }, + { start: 0, count: 1 }, + { start: 2, count: 2 }, + { start: 1, count: 15 }, + { start: 15, count: undefined }, +]; + +function test_frame_slice() { + if (gSliceTests.length == 0) { + gThreadClient.resume(function () { finishClient(gClient); }); + return; + } + + let test = gSliceTests.shift(); + gThreadClient.getFrames(test.start, test.count, function (aResponse) { + var testFrames = gFrames.slice(test.start, test.count ? test.start + test.count : undefined); + do_check_eq(testFrames.length, aResponse.frames.length); + for (var i = 0; i < testFrames.length; i++) { + let expected = testFrames[i]; + let actual = aResponse.frames[i]; + + if (test.resetActors) { + expected.actor = actual.actor; + } + + for (let key of ["type", "callee-name"]) { + do_check_eq(expected[key] || undefined, actual[key]); + } + } + test_frame_slice(); + }); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket1) { + test_frame_slice(); + }); + + gDebuggee.eval("(" + function () { + function depth3() { + debugger; + } + function depth2() { + depth3(); + } + function depth1() { + depth2(); + } + depth1(); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_frameactor-05.js b/devtools/server/tests/unit/test_frameactor-05.js new file mode 100644 index 000000000..feece598e --- /dev/null +++ b/devtools/server/tests/unit/test_frameactor-05.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Verify that frame actors retrieved with the frames request + * are included in the pause packet's popped-frames property. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_frame_slice() { + if (gSliceTests.length == 0) { + gThreadClient.resume(function () { finishClient(gClient); }); + return; + } + + let test = gSliceTests.shift(); + gThreadClient.getFrames(test.start, test.count, function (aResponse) { + var testFrames = gFrames.slice(test.start, test.count ? test.start + test.count : undefined); + do_check_eq(testFrames.length, aResponse.frames.length); + for (var i = 0; i < testFrames.length; i++) { + let expected = testFrames[i]; + let actual = aResponse.frames[i]; + + if (test.resetActors) { + expected.actor = actual.actor; + } + + for (var key in expected) { + do_check_eq(expected[key], actual[key]); + } + } + test_frame_slice(); + }); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket1) { + gThreadClient.getFrames(0, null, function (aFrameResponse) { + do_check_eq(aFrameResponse.frames.length, 5); + // Now wait for the next pause, after which the three + // youngest actors should be popped.. + let expectPopped = aFrameResponse.frames.slice(0, 3).map(frame => frame.actor); + expectPopped.sort(); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPausePacket) { + let popped = aPausePacket.poppedFrames.sort(); + do_check_eq(popped.length, 3); + for (let i = 0; i < 3; i++) { + do_check_eq(expectPopped[i], popped[i]); + } + + gThreadClient.resume(function () { finishClient(gClient); }); + }); + gThreadClient.resume(); + }); + }); + + gDebuggee.eval("(" + function () { + function depth3() { + debugger; + } + function depth2() { + depth3(); + } + function depth1() { + depth2(); + } + depth1(); + debugger; + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_framearguments-01.js b/devtools/server/tests/unit/test_framearguments-01.js new file mode 100644 index 000000000..e075d2c25 --- /dev/null +++ b/devtools/server/tests/unit/test_framearguments-01.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check a frame actor's arguments property. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame["arguments"]; + do_check_eq(args.length, 6); + do_check_eq(args[0], 42); + do_check_eq(args[1], true); + do_check_eq(args[2], "nasu"); + do_check_eq(args[3].type, "null"); + do_check_eq(args[4].type, "undefined"); + do_check_eq(args[5].type, "object"); + do_check_eq(args[5].class, "Object"); + do_check_true(!!args[5].actor); + + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe(aNumber, aBool, aString, aNull, aUndefined, aObject) { + debugger; + } + stopMe(42, true, "nasu", null, undefined, { foo: "bar" }); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_framebindings-01.js b/devtools/server/tests/unit/test_framebindings-01.js new file mode 100644 index 000000000..ae62f8ff1 --- /dev/null +++ b/devtools/server/tests/unit/test_framebindings-01.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check a frame actor's bindings property. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let bindings = aPacket.frame.environment.bindings; + let args = bindings.arguments; + let vars = bindings.variables; + + do_check_eq(args.length, 6); + do_check_eq(args[0].aNumber.value, 42); + do_check_eq(args[1].aBool.value, true); + do_check_eq(args[2].aString.value, "nasu"); + do_check_eq(args[3].aNull.value.type, "null"); + do_check_eq(args[4].aUndefined.value.type, "undefined"); + do_check_eq(args[5].aObject.value.type, "object"); + do_check_eq(args[5].aObject.value.class, "Object"); + do_check_true(!!args[5].aObject.value.actor); + + do_check_eq(vars.a.value, 1); + do_check_eq(vars.b.value, true); + do_check_eq(vars.c.value.type, "object"); + do_check_eq(vars.c.value.class, "Object"); + do_check_true(!!vars.c.value.actor); + + let objClient = gThreadClient.pauseGrip(vars.c.value); + objClient.getPrototypeAndProperties(function (aResponse) { + do_check_eq(aResponse.ownProperties.a.configurable, true); + do_check_eq(aResponse.ownProperties.a.enumerable, true); + do_check_eq(aResponse.ownProperties.a.writable, true); + do_check_eq(aResponse.ownProperties.a.value, "a"); + + do_check_eq(aResponse.ownProperties.b.configurable, true); + do_check_eq(aResponse.ownProperties.b.enumerable, true); + do_check_eq(aResponse.ownProperties.b.writable, true); + do_check_eq(aResponse.ownProperties.b.value.type, "undefined"); + do_check_false("class" in aResponse.ownProperties.b.value); + + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe(aNumber, aBool, aString, aNull, aUndefined, aObject) { + var a = 1; + var b = true; + var c = { a: "a", b: undefined }; + debugger; + } + stopMe(42, true, "nasu", null, undefined, { foo: "bar" }); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_framebindings-02.js b/devtools/server/tests/unit/test_framebindings-02.js new file mode 100644 index 000000000..552670349 --- /dev/null +++ b/devtools/server/tests/unit/test_framebindings-02.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check a frame actor's parent bindings. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let parentEnv = aPacket.frame.environment.parent; + let bindings = parentEnv.bindings; + let args = bindings.arguments; + let vars = bindings.variables; + do_check_neq(parentEnv, undefined); + do_check_eq(args.length, 0); + do_check_eq(vars.stopMe.value.type, "object"); + do_check_eq(vars.stopMe.value.class, "Function"); + do_check_true(!!vars.stopMe.value.actor); + + // Skip the global lexical scope. + parentEnv = parentEnv.parent.parent; + do_check_neq(parentEnv, undefined); + let objClient = gThreadClient.pauseGrip(parentEnv.object); + objClient.getPrototypeAndProperties(function (aResponse) { + do_check_eq(aResponse.ownProperties.Object.value.type, "object"); + do_check_eq(aResponse.ownProperties.Object.value.class, "Function"); + do_check_true(!!aResponse.ownProperties.Object.value.actor); + + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe(aNumber, aBool, aString, aNull, aUndefined, aObject) { + var a = 1; + var b = true; + var c = { a: "a" }; + debugger; + } + stopMe(42, true, "nasu", null, undefined, { foo: "bar" }); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_framebindings-03.js b/devtools/server/tests/unit/test_framebindings-03.js new file mode 100644 index 000000000..1ec51570d --- /dev/null +++ b/devtools/server/tests/unit/test_framebindings-03.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check a |with| frame actor's bindings. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let env = aPacket.frame.environment; + do_check_neq(env, undefined); + + let parentEnv = env.parent; + do_check_neq(parentEnv, undefined); + + let bindings = parentEnv.bindings; + let args = bindings.arguments; + let vars = bindings.variables; + do_check_eq(args.length, 1); + do_check_eq(args[0].aNumber.value, 10); + do_check_eq(vars.r.value, 10); + do_check_eq(vars.a.value, Math.PI * 100); + do_check_eq(vars.arguments.value.class, "Arguments"); + do_check_true(!!vars.arguments.value.actor); + + let objClient = gThreadClient.pauseGrip(env.object); + objClient.getPrototypeAndProperties(function (aResponse) { + do_check_eq(aResponse.ownProperties.PI.value, Math.PI); + do_check_eq(aResponse.ownProperties.cos.value.type, "object"); + do_check_eq(aResponse.ownProperties.cos.value.class, "Function"); + do_check_true(!!aResponse.ownProperties.cos.value.actor); + + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe(aNumber) { + var a; + var r = aNumber; + with (Math) { + a = PI * r * r; + debugger; + } + } + stopMe(10); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_framebindings-04.js b/devtools/server/tests/unit/test_framebindings-04.js new file mode 100644 index 000000000..963a12055 --- /dev/null +++ b/devtools/server/tests/unit/test_framebindings-04.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check the environment bindongs of a |with| within a |with|. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let env = aPacket.frame.environment; + do_check_neq(env, undefined); + + let objClient = gThreadClient.pauseGrip(env.object); + objClient.getPrototypeAndProperties(function (aResponse) { + do_check_eq(aResponse.ownProperties.one.value, 1); + do_check_eq(aResponse.ownProperties.two.value, 2); + do_check_eq(aResponse.ownProperties.foo, undefined); + + let parentEnv = env.parent; + do_check_neq(parentEnv, undefined); + + let parentClient = gThreadClient.pauseGrip(parentEnv.object); + parentClient.getPrototypeAndProperties(function (aResponse) { + do_check_eq(aResponse.ownProperties.PI.value, Math.PI); + do_check_eq(aResponse.ownProperties.cos.value.type, "object"); + do_check_eq(aResponse.ownProperties.cos.value.class, "Function"); + do_check_true(!!aResponse.ownProperties.cos.value.actor); + + parentEnv = parentEnv.parent; + do_check_neq(parentEnv, undefined); + + let bindings = parentEnv.bindings; + let args = bindings.arguments; + let vars = bindings.variables; + do_check_eq(args.length, 1); + do_check_eq(args[0].aNumber.value, 10); + do_check_eq(vars.r.value, 10); + do_check_eq(vars.a.value, Math.PI * 100); + do_check_eq(vars.arguments.value.class, "Arguments"); + do_check_true(!!vars.arguments.value.actor); + do_check_eq(vars.foo.value, 2 * Math.PI); + + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + + }); + + gDebuggee.eval("(" + function () { + function stopMe(aNumber) { + var a, obj = { one: 1, two: 2 }; + var r = aNumber; + with (Math) { + a = PI * r * r; + with (obj) { + var foo = two * PI; + debugger; + } + } + } + stopMe(10); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_framebindings-05.js b/devtools/server/tests/unit/test_framebindings-05.js new file mode 100644 index 000000000..9827c617a --- /dev/null +++ b/devtools/server/tests/unit/test_framebindings-05.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check the environment bindings of a |with| in global scope. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let env = aPacket.frame.environment; + do_check_neq(env, undefined); + + let objClient = gThreadClient.pauseGrip(env.object); + objClient.getPrototypeAndProperties(function (aResponse) { + do_check_eq(aResponse.ownProperties.PI.value, Math.PI); + do_check_eq(aResponse.ownProperties.cos.value.type, "object"); + do_check_eq(aResponse.ownProperties.cos.value.class, "Function"); + do_check_true(!!aResponse.ownProperties.cos.value.actor); + + // Skip the global lexical scope. + let parentEnv = env.parent.parent; + do_check_neq(parentEnv, undefined); + + let parentClient = gThreadClient.pauseGrip(parentEnv.object); + parentClient.getPrototypeAndProperties(function (aResponse) { + do_check_eq(aResponse.ownProperties.a.value, Math.PI * 100); + do_check_eq(aResponse.ownProperties.r.value, 10); + do_check_eq(aResponse.ownProperties.Object.value.type, "object"); + do_check_eq(aResponse.ownProperties.Object.value.class, "Function"); + do_check_true(!!aResponse.ownProperties.Object.value.actor); + + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + }); + + gDebuggee.eval("var a, r = 10;\n" + + "with (Math) {\n" + + " a = PI * r * r;\n" + + " debugger;\n" + + "}"); +} diff --git a/devtools/server/tests/unit/test_framebindings-06.js b/devtools/server/tests/unit/test_framebindings-06.js new file mode 100644 index 000000000..9d8478a29 --- /dev/null +++ b/devtools/server/tests/unit/test_framebindings-06.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-grips"); + + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_banana_environment(); + }); + }); + do_test_pending(); +} + +function test_banana_environment() +{ + + gThreadClient.addOneTimeListener("paused", + function (aEvent, aPacket) { + equal(aPacket.type, "paused"); + let env = aPacket.frame.environment; + equal(env.type, "function"); + equal(env.function.name, "banana3"); + let parent = env.parent; + equal(parent.type, "block"); + ok("banana3" in parent.bindings.variables); + parent = parent.parent; + equal(parent.type, "function"); + equal(parent.function.name, "banana2"); + parent = parent.parent; + equal(parent.type, "block"); + ok("banana2" in parent.bindings.variables); + parent = parent.parent; + equal(parent.type, "function"); + equal(parent.function.name, "banana"); + + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + + gDebuggee.eval("\ + function banana(x) { \n\ + return function banana2(y) { \n\ + return function banana3(z) { \n\ + debugger; \n\ + }; \n\ + }; \n\ + } \n\ + banana('x')('y')('z'); \n\ + "); +} diff --git a/devtools/server/tests/unit/test_framebindings-07.js b/devtools/server/tests/unit/test_framebindings-07.js new file mode 100644 index 000000000..bdfc36d97 --- /dev/null +++ b/devtools/server/tests/unit/test_framebindings-07.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gDebuggee; +var gClient; +var gThreadClient; + +// Test that the EnvironmentClient's getBindings() method works as expected. +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-bindings"); + + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-bindings", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_banana_environment(); + }); + }); + do_test_pending(); +} + +function test_banana_environment() +{ + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let environment = aPacket.frame.environment; + do_check_eq(environment.type, "function"); + + let parent = environment.parent; + do_check_eq(parent.type, "block"); + + let grandpa = parent.parent; + do_check_eq(grandpa.type, "function"); + + let envClient = gThreadClient.environment(environment); + envClient.getBindings(aResponse => { + do_check_eq(aResponse.bindings.arguments[0].z.value, "z"); + + let parentClient = gThreadClient.environment(parent); + parentClient.getBindings(aResponse => { + do_check_eq(aResponse.bindings.variables.banana3.value.class, "Function"); + + let grandpaClient = gThreadClient.environment(grandpa); + grandpaClient.getBindings(aResponse => { + do_check_eq(aResponse.bindings.arguments[0].y.value, "y"); + gThreadClient.resume(() => finishClient(gClient)); + }); + }); + }); + }); + + gDebuggee.eval("\ + function banana(x) { \n\ + return function banana2(y) { \n\ + return function banana3(z) { \n\ + debugger; \n\ + }; \n\ + }; \n\ + } \n\ + banana('x')('y')('z'); \n\ + "); +} diff --git a/devtools/server/tests/unit/test_frameclient-01.js b/devtools/server/tests/unit/test_frameclient-01.js new file mode 100644 index 000000000..a441c9ade --- /dev/null +++ b/devtools/server/tests/unit/test_frameclient-01.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.addOneTimeListener("framesadded", function () { + do_check_eq(gThreadClient.cachedFrames.length, 3); + do_check_true(gThreadClient.moreFrames); + do_check_false(gThreadClient.fillFrames(3)); + + do_check_true(gThreadClient.fillFrames(30)); + gThreadClient.addOneTimeListener("framesadded", function () { + do_check_false(gThreadClient.moreFrames); + do_check_eq(gThreadClient.cachedFrames.length, 7); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + do_check_true(gThreadClient.fillFrames(3)); + }); + + gDebuggee.eval("(" + function () { + var recurseLeft = 5; + function recurse() { + if (--recurseLeft == 0) { + debugger; + return; + } + recurse(); + } + recurse(); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_frameclient-02.js b/devtools/server/tests/unit/test_frameclient-02.js new file mode 100644 index 000000000..a257e5960 --- /dev/null +++ b/devtools/server/tests/unit/test_frameclient-02.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Ask for exactly the number of frames we expect. + gThreadClient.addOneTimeListener("framesadded", function () { + do_check_false(gThreadClient.moreFrames); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + do_check_true(gThreadClient.fillFrames(3)); + }); + + gDebuggee.eval("(" + function () { + var recurseLeft = 1; + function recurse() { + if (--recurseLeft == 0) { + debugger; + return; + } + recurse(); + } + recurse(); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_functiongrips-01.js b/devtools/server/tests/unit/test_functiongrips-01.js new file mode 100644 index 000000000..c41a7cad5 --- /dev/null +++ b/devtools/server/tests/unit/test_functiongrips-01.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-grips"); + gDebuggee.eval(function stopMe(arg1) { + debugger; + }.toString()); + + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_named_function(); + }); + }); + do_test_pending(); +} + +function test_named_function() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame.arguments; + + do_check_eq(args[0].class, "Function"); + do_check_eq(args[0].name, "stopMe"); + do_check_eq(args[0].displayName, "stopMe"); + + let objClient = gThreadClient.pauseGrip(args[0]); + objClient.getParameterNames(function (aResponse) { + do_check_eq(aResponse.parameterNames.length, 1); + do_check_eq(aResponse.parameterNames[0], "arg1"); + + gThreadClient.resume(test_inferred_name_function); + }); + + }); + + gDebuggee.eval("stopMe(stopMe)"); +} + +function test_inferred_name_function() { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame.arguments; + + do_check_eq(args[0].class, "Function"); + // No name for an anonymous function, but it should have an inferred name. + do_check_eq(args[0].name, undefined); + do_check_eq(args[0].displayName, "o.m"); + + let objClient = gThreadClient.pauseGrip(args[0]); + objClient.getParameterNames(function (aResponse) { + do_check_eq(aResponse.parameterNames.length, 3); + do_check_eq(aResponse.parameterNames[0], "foo"); + do_check_eq(aResponse.parameterNames[1], "bar"); + do_check_eq(aResponse.parameterNames[2], "baz"); + + gThreadClient.resume(test_anonymous_function); + }); + }); + + gDebuggee.eval("var o = { m: function(foo, bar, baz) { } }; stopMe(o.m)"); +} + +function test_anonymous_function() { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame.arguments; + + do_check_eq(args[0].class, "Function"); + // No name for an anonymous function, and no inferred name, either. + do_check_eq(args[0].name, undefined); + do_check_eq(args[0].displayName, undefined); + + let objClient = gThreadClient.pauseGrip(args[0]); + objClient.getParameterNames(function (aResponse) { + do_check_eq(aResponse.parameterNames.length, 3); + do_check_eq(aResponse.parameterNames[0], "foo"); + do_check_eq(aResponse.parameterNames[1], "bar"); + do_check_eq(aResponse.parameterNames[2], "baz"); + + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + + gDebuggee.eval("stopMe(function(foo, bar, baz) { })"); +} + diff --git a/devtools/server/tests/unit/test_get-executable-lines-source-map.js b/devtools/server/tests/unit/test_get-executable-lines-source-map.js new file mode 100644 index 000000000..bca8eebee --- /dev/null +++ b/devtools/server/tests/unit/test_get-executable-lines-source-map.js @@ -0,0 +1,56 @@ +/* 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/. */ + +/** + * Test if getExecutableLines return correct information + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +const SOURCE_MAPPED_FILE = getFileUrl("sourcemapped.js"); + +function run_test() { + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-get-executable-lines"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function _onConnect() { + attachTestTabAndResume( + gClient, + "test-get-executable-lines", + function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_executable_lines(); + } + ); + }); + + do_test_pending(); +} + +function test_executable_lines() { + gThreadClient.addOneTimeListener("newSource", function _onNewSource(evt, packet) { + do_check_eq(evt, "newSource"); + + gThreadClient.getSources(function ({error, sources}) { + do_check_true(!error); + let source = gThreadClient.source(sources[0]); + source.getExecutableLines(function (lines) { + do_check_true(arrays_equal([1, 2, 4, 6], lines)); + finishClient(gClient); + }); + }); + }); + + let code = readFile("sourcemapped.js") + "\n//# sourceMappingURL=" + + getFileUrl("source-map-data/sourcemapped.map"); + + Components.utils.evalInSandbox(code, gDebuggee, "1.8", + SOURCE_MAPPED_FILE, 1); +} + +function arrays_equal(a, b) { + return !(a < b || b < a); +} diff --git a/devtools/server/tests/unit/test_get-executable-lines.js b/devtools/server/tests/unit/test_get-executable-lines.js new file mode 100644 index 000000000..233fb6ada --- /dev/null +++ b/devtools/server/tests/unit/test_get-executable-lines.js @@ -0,0 +1,55 @@ +/* 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/. */ + +/** + * Test if getExecutableLines return correct information + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +const SOURCE_MAPPED_FILE = getFileUrl("sourcemapped.js"); + +function run_test() { + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-get-executable-lines"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function _onConnect() { + attachTestTabAndResume( + gClient, + "test-get-executable-lines", + function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_executable_lines(); + } + ); + }); + + do_test_pending(); +} + +function test_executable_lines() { + gThreadClient.addOneTimeListener("newSource", function _onNewSource(evt, packet) { + do_check_eq(evt, "newSource"); + + gThreadClient.getSources(function ({error, sources}) { + do_check_true(!error); + let source = gThreadClient.source(sources[0]); + source.getExecutableLines(function (lines) { + do_check_true(arrays_equal([2, 5, 7, 8, 10, 12, 14, 16], lines)); + finishClient(gClient); + }); + }); + }); + + let code = readFile("sourcemapped.js"); + + Components.utils.evalInSandbox(code, gDebuggee, "1.8", + SOURCE_MAPPED_FILE, 1); +} + +function arrays_equal(a, b) { + return !(a < b || b < a); +} diff --git a/devtools/server/tests/unit/test_getRuleText.js b/devtools/server/tests/unit/test_getRuleText.js new file mode 100644 index 000000000..fe735928d --- /dev/null +++ b/devtools/server/tests/unit/test_getRuleText.js @@ -0,0 +1,137 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set 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 {getRuleText} = require("devtools/server/actors/styles"); + +const TEST_DATA = [ + { + desc: "Empty input", + input: "", + line: 1, + column: 1, + throws: true + }, + { + desc: "Simplest test case", + input: "#id{color:red;background:yellow;}", + line: 1, + column: 1, + expected: {offset: 4, text: "color:red;background:yellow;"} + }, + { + desc: "Multiple rules test case", + input: "#id{color:red;background:yellow;}.class-one .class-two { position:absolute; line-height: 45px}", + line: 1, + column: 34, + expected: {offset: 56, text: " position:absolute; line-height: 45px"} + }, + { + desc: "Unclosed rule", + input: "#id{color:red;background:yellow;", + line: 1, + column: 1, + expected: {offset: 4, text: "color:red;background:yellow;"} + }, + { + desc: "Null input", + input: null, + line: 1, + column: 1, + throws: true + }, + { + desc: "Missing loc", + input: "#id{color:red;background:yellow;}", + throws: true + }, + { + desc: "Multi-lines CSS", + input: [ + "/* this is a multi line css */", + "body {", + " color: green;", + " background-repeat: no-repeat", + "}", + " /*something else here */", + "* {", + " color: purple;", + "}" + ].join("\n"), + line: 7, + column: 1, + expected: {offset: 116, text: "\n color: purple;\n"} + }, + { + desc: "Multi-lines CSS and multi-line rule", + input: [ + "/* ", + "* some comments", + "*/", + "", + "body {", + " margin: 0;", + " padding: 15px 15px 2px 15px;", + " color: red;", + "}", + "", + "#header .btn, #header .txt {", + " font-size: 100%;", + "}", + "", + "#header #information {", + " color: #dddddd;", + " font-size: small;", + "}", + ].join("\n"), + line: 5, + column: 1, + expected: { + offset: 30, + text: "\n margin: 0;\n padding: 15px 15px 2px 15px;\n color: red;\n"} + }, + { + desc: "Content string containing a } character", + input: " #id{border:1px solid red;content: '}';color:red;}", + line: 1, + column: 4, + expected: {offset: 7, text: "border:1px solid red;content: '}';color:red;"} + }, + { + desc: "Rule contains no tokens", + input: "div{}", + line: 1, + column: 1, + expected: {offset: 4, text: ""} + }, +]; + +function run_test() { + for (let test of TEST_DATA) { + do_print("Starting test: " + test.desc); + do_print("Input string " + test.input); + let output; + try { + output = getRuleText(test.input, test.line, test.column); + if (test.throws) { + do_print("Test should have thrown"); + do_check_true(false); + } + } catch (e) { + do_print("getRuleText threw an exception with the given input string"); + if (test.throws) { + do_print("Exception expected"); + do_check_true(true); + } else { + do_print("Exception unexpected\n" + e); + do_check_true(false); + } + } + if (output) { + deepEqual(output, test.expected); + } + } +} diff --git a/devtools/server/tests/unit/test_getTextAtLineColumn.js b/devtools/server/tests/unit/test_getTextAtLineColumn.js new file mode 100644 index 000000000..16ec47608 --- /dev/null +++ b/devtools/server/tests/unit/test_getTextAtLineColumn.js @@ -0,0 +1,35 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set 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 {getTextAtLineColumn} = require("devtools/server/actors/styles"); + +const TEST_DATA = [ + { + desc: "simplest", + input: "#id{color:red;background:yellow;}", + line: 1, + column: 5, + expected: {offset: 4, text: "color:red;background:yellow;}"} + }, + { + desc: "multiple lines", + input: "one\n two\n three", + line: 3, + column: 3, + expected: {offset: 11, text: "three"} + }, +]; + +function run_test() { + for (let test of TEST_DATA) { + do_print("Starting test: " + test.desc); + do_print("Input string " + test.input); + + let output = getTextAtLineColumn(test.input, test.line, test.column); + deepEqual(output, test.expected); + } +} diff --git a/devtools/server/tests/unit/test_getyoungestframe.js b/devtools/server/tests/unit/test_getyoungestframe.js new file mode 100644 index 000000000..035ab5b0c --- /dev/null +++ b/devtools/server/tests/unit/test_getyoungestframe.js @@ -0,0 +1,30 @@ +function run_test() +{ + Components.utils.import("resource://gre/modules/jsdebugger.jsm"); + addDebuggerToGlobal(this); + var xpcInspector = Cc["@mozilla.org/jsinspector;1"].getService(Ci.nsIJSInspector); + var g = testGlobal("test1"); + + var dbg = new Debugger(); + dbg.uncaughtExceptionHook = testExceptionHook; + + dbg.addDebuggee(g); + dbg.onDebuggerStatement = function (aFrame) { + do_check_true(aFrame === dbg.getNewestFrame()); + // Execute from the nested event loop, dbg.getNewestFrame() won't + // be working anymore. + + do_execute_soon(function () { + try { + do_check_true(aFrame === dbg.getNewestFrame()); + } finally { + xpcInspector.exitNestedEventLoop("test"); + } + }); + xpcInspector.enterNestedEventLoop("test"); + }; + + g.eval("function debuggerStatement() { debugger; }; debuggerStatement();"); + + dbg.enabled = false; +} diff --git a/devtools/server/tests/unit/test_ignore_caught_exceptions.js b/devtools/server/tests/unit/test_ignore_caught_exceptions.js new file mode 100644 index 000000000..a4b221823 --- /dev/null +++ b/devtools/server/tests/unit/test_ignore_caught_exceptions.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that setting ignoreCaughtExceptions will cause the debugger to ignore + * caught exceptions, but not uncaught ones. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aPacket.why.type, "exception"); + do_check_eq(aPacket.why.exception, "bar"); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + gThreadClient.pauseOnExceptions(true, true); + gThreadClient.resume(); + }); + + try { + gDebuggee.eval("(" + function () { + debugger; + try { + throw "foo"; + } catch (e) {} + throw "bar"; + } + ")()"); + } catch (e) {} +} diff --git a/devtools/server/tests/unit/test_ignore_no_interface_exceptions.js b/devtools/server/tests/unit/test_ignore_no_interface_exceptions.js new file mode 100644 index 000000000..5aaa31de3 --- /dev/null +++ b/devtools/server/tests/unit/test_ignore_no_interface_exceptions.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that the debugger automatically ignores NS_ERROR_NO_INTERFACE + * exceptions, but not normal ones. + */ + + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-no-interface"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-no-interface", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.pauseOnExceptions(true, false, function () { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aPacket.why.type, "exception"); + do_check_eq(aPacket.why.exception, 42); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + + gDebuggee.eval("(" + function () { + function QueryInterface() { + throw Components.results.NS_ERROR_NO_INTERFACE; + } + function stopMe() { + throw 42; + } + try { + QueryInterface(); + } catch (e) {} + try { + stopMe(); + } catch (e) {} + } + ")()"); + }); +} diff --git a/devtools/server/tests/unit/test_interrupt.js b/devtools/server/tests/unit/test_interrupt.js new file mode 100644 index 000000000..34835cc0a --- /dev/null +++ b/devtools/server/tests/unit/test_interrupt.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gClient; +var gDebuggee; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = testGlobal("test-1"); + DebuggerServer.addTestGlobal(gDebuggee); + + let transport = DebuggerServer.connectPipe(); + gClient = new DebuggerClient(transport); + gClient.connect().then(function (aType, aTraits) { + attachTestTab(gClient, "test-1", test_attach); + }); + do_test_pending(); +} + +function test_attach(aResponse, aTabClient) +{ + aTabClient.attachThread({}, function (aResponse, aThreadClient) { + do_check_eq(aThreadClient.paused, true); + aThreadClient.resume(function () { + test_interrupt(aThreadClient); + }); + }); +} + +function test_interrupt(aThreadClient) +{ + do_check_eq(aThreadClient.paused, false); + aThreadClient.interrupt(function (aResponse) { + do_check_eq(aThreadClient.paused, true); + aThreadClient.resume(function () { + do_check_eq(aThreadClient.paused, false); + cleanup(); + }); + }); +} + +function cleanup() +{ + gClient.addListener("closed", function (aEvent) { + do_test_finished(); + }); + gClient.close(); +} + diff --git a/devtools/server/tests/unit/test_layout-reflows-observer.js b/devtools/server/tests/unit/test_layout-reflows-observer.js new file mode 100644 index 000000000..ff6c07b26 --- /dev/null +++ b/devtools/server/tests/unit/test_layout-reflows-observer.js @@ -0,0 +1,286 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test the LayoutChangesObserver + +var { + getLayoutChangesObserver, + releaseLayoutChangesObserver, + LayoutChangesObserver +} = require("devtools/server/actors/reflow"); + +// Override set/clearTimeout on LayoutChangesObserver to avoid depending on +// time in this unit test. This means that LayoutChangesObserver.eventLoopTimer +// will be the timeout callback instead of the timeout itself, so test cases +// will need to execute it to fake a timeout +LayoutChangesObserver.prototype._setTimeout = cb => cb; +LayoutChangesObserver.prototype._clearTimeout = function () {}; + +// Mock the tabActor since we only really want to test the LayoutChangesObserver +// and don't want to depend on a window object, nor want to test protocol.js +function MockTabActor() { + this.window = new MockWindow(); + this.windows = [this.window]; + this.attached = true; +} + +function MockWindow() {} +MockWindow.prototype = { + QueryInterface: function () { + let self = this; + return { + getInterface: function () { + return { + QueryInterface: function () { + if (!self.docShell) { + self.docShell = new MockDocShell(); + } + return self.docShell; + } + }; + } + }; + }, + setTimeout: function (cb) { + // Simply return the cb itself so that we can execute it in the test instead + // of depending on a real timeout + return cb; + }, + clearTimeout: function () {} +}; + +function MockDocShell() { + this.observer = null; +} +MockDocShell.prototype = { + addWeakReflowObserver: function (observer) { + this.observer = observer; + }, + removeWeakReflowObserver: function () {}, + get chromeEventHandler() { + return { + addEventListener: (type, cb) => { + if (type === "resize") { + this.resizeCb = cb; + } + }, + removeEventListener: (type, cb) => { + if (type === "resize" && cb === this.resizeCb) { + this.resizeCb = null; + } + } + }; + }, + mockResize: function () { + if (this.resizeCb) { + this.resizeCb(); + } + } +}; + +function run_test() { + instancesOfObserversAreSharedBetweenWindows(); + eventsAreBatched(); + noEventsAreSentWhenThereAreNoReflowsAndLoopTimeouts(); + observerIsAlreadyStarted(); + destroyStopsObserving(); + stoppingAndStartingSeveralTimesWorksCorrectly(); + reflowsArentStackedWhenStopped(); + stackedReflowsAreResetOnStop(); +} + +function instancesOfObserversAreSharedBetweenWindows() { + do_print("Checking that when requesting twice an instances of the observer " + + "for the same TabActor, the instance is shared"); + + do_print("Checking 2 instances of the observer for the tabActor 1"); + let tabActor1 = new MockTabActor(); + let obs11 = getLayoutChangesObserver(tabActor1); + let obs12 = getLayoutChangesObserver(tabActor1); + do_check_eq(obs11, obs12); + + do_print("Checking 2 instances of the observer for the tabActor 2"); + let tabActor2 = new MockTabActor(); + let obs21 = getLayoutChangesObserver(tabActor2); + let obs22 = getLayoutChangesObserver(tabActor2); + do_check_eq(obs21, obs22); + + do_print("Checking that observers instances for 2 different tabActors are " + + "different"); + do_check_neq(obs11, obs21); + + releaseLayoutChangesObserver(tabActor1); + releaseLayoutChangesObserver(tabActor1); + releaseLayoutChangesObserver(tabActor2); + releaseLayoutChangesObserver(tabActor2); +} + +function eventsAreBatched() { + do_print("Checking that reflow events are batched and only sent when the " + + "timeout expires"); + + // Note that in this test, we mock the TabActor and its window property, so we + // also mock the setTimeout/clearTimeout mechanism and just call the callback + // manually + let tabActor = new MockTabActor(); + let observer = getLayoutChangesObserver(tabActor); + + let reflowsEvents = []; + let onReflows = (event, reflows) => reflowsEvents.push(reflows); + observer.on("reflows", onReflows); + + let resizeEvents = []; + let onResize = () => resizeEvents.push("resize"); + observer.on("resize", onResize); + + do_print("Fake one reflow event"); + tabActor.window.docShell.observer.reflow(); + do_print("Checking that no batched reflow event has been emitted"); + do_check_eq(reflowsEvents.length, 0); + + do_print("Fake another reflow event"); + tabActor.window.docShell.observer.reflow(); + do_print("Checking that still no batched reflow event has been emitted"); + do_check_eq(reflowsEvents.length, 0); + + do_print("Fake a few of resize events too"); + tabActor.window.docShell.mockResize(); + tabActor.window.docShell.mockResize(); + tabActor.window.docShell.mockResize(); + do_print("Checking that still no batched resize event has been emitted"); + do_check_eq(resizeEvents.length, 0); + + do_print("Faking timeout expiration and checking that events are sent"); + observer.eventLoopTimer(); + do_check_eq(reflowsEvents.length, 1); + do_check_eq(reflowsEvents[0].length, 2); + do_check_eq(resizeEvents.length, 1); + + observer.off("reflows", onReflows); + observer.off("resize", onResize); + releaseLayoutChangesObserver(tabActor); +} + +function noEventsAreSentWhenThereAreNoReflowsAndLoopTimeouts() { + do_print("Checking that if no reflows were detected and the event batching " + + "loop expires, then no reflows event is sent"); + + let tabActor = new MockTabActor(); + let observer = getLayoutChangesObserver(tabActor); + + let reflowsEvents = []; + let onReflows = (event, reflows) => reflowsEvents.push(reflows); + observer.on("reflows", onReflows); + + do_print("Faking timeout expiration and checking for reflows"); + observer.eventLoopTimer(); + do_check_eq(reflowsEvents.length, 0); + + observer.off("reflows", onReflows); + releaseLayoutChangesObserver(tabActor); +} + +function observerIsAlreadyStarted() { + do_print("Checking that the observer is already started when getting it"); + + let tabActor = new MockTabActor(); + let observer = getLayoutChangesObserver(tabActor); + do_check_true(observer.isObserving); + + observer.stop(); + do_check_false(observer.isObserving); + + observer.start(); + do_check_true(observer.isObserving); + + releaseLayoutChangesObserver(tabActor); +} + +function destroyStopsObserving() { + do_print("Checking that the destroying the observer stops it"); + + let tabActor = new MockTabActor(); + let observer = getLayoutChangesObserver(tabActor); + do_check_true(observer.isObserving); + + observer.destroy(); + do_check_false(observer.isObserving); + + releaseLayoutChangesObserver(tabActor); +} + +function stoppingAndStartingSeveralTimesWorksCorrectly() { + do_print("Checking that the stopping and starting several times the observer" + + " works correctly"); + + let tabActor = new MockTabActor(); + let observer = getLayoutChangesObserver(tabActor); + + do_check_true(observer.isObserving); + observer.start(); + observer.start(); + observer.start(); + do_check_true(observer.isObserving); + + observer.stop(); + do_check_false(observer.isObserving); + + observer.stop(); + observer.stop(); + do_check_false(observer.isObserving); + + releaseLayoutChangesObserver(tabActor); +} + +function reflowsArentStackedWhenStopped() { + do_print("Checking that when stopped, reflows aren't stacked in the observer"); + + let tabActor = new MockTabActor(); + let observer = getLayoutChangesObserver(tabActor); + + do_print("Stoping the observer"); + observer.stop(); + + do_print("Faking reflows"); + tabActor.window.docShell.observer.reflow(); + tabActor.window.docShell.observer.reflow(); + tabActor.window.docShell.observer.reflow(); + + do_print("Checking that reflows aren't recorded"); + do_check_eq(observer.reflows.length, 0); + + do_print("Starting the observer and faking more reflows"); + observer.start(); + tabActor.window.docShell.observer.reflow(); + tabActor.window.docShell.observer.reflow(); + tabActor.window.docShell.observer.reflow(); + + do_print("Checking that reflows are recorded"); + do_check_eq(observer.reflows.length, 3); + + releaseLayoutChangesObserver(tabActor); +} + +function stackedReflowsAreResetOnStop() { + do_print("Checking that stacked reflows are reset on stop"); + + let tabActor = new MockTabActor(); + let observer = getLayoutChangesObserver(tabActor); + + tabActor.window.docShell.observer.reflow(); + do_check_eq(observer.reflows.length, 1); + + observer.stop(); + do_check_eq(observer.reflows.length, 0); + + tabActor.window.docShell.observer.reflow(); + do_check_eq(observer.reflows.length, 0); + + observer.start(); + do_check_eq(observer.reflows.length, 0); + + tabActor.window.docShell.observer.reflow(); + do_check_eq(observer.reflows.length, 1); + + releaseLayoutChangesObserver(tabActor); +} diff --git a/devtools/server/tests/unit/test_listsources-01.js b/devtools/server/tests/unit/test_listsources-01.js new file mode 100644 index 000000000..231e6a1e4 --- /dev/null +++ b/devtools/server/tests/unit/test_listsources-01.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check basic getSources functionality. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +var gNumTimesSourcesSent = 0; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.request = (function (request) { + return function (aRequest, aOnResponse) { + if (aRequest.type === "sources") { + ++gNumTimesSourcesSent; + } + return request.call(this, aRequest, aOnResponse); + }; + }(gClient.request)); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_listsources(); + }); + }); + do_test_pending(); +} + +function test_simple_listsources() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.getSources(function (aResponse) { + do_check_true(aResponse.sources.some(function (s) { + return s.url && s.url.match(/test_listsources-01.js/); + })); + + do_check_true(gNumTimesSourcesSent <= 1, + "Should only send one sources request at most, even though we" + + " might have had to send one to determine feature support."); + + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + + Components.utils.evalInSandbox("var line0 = Error().lineNumber;\n" + + "debugger;\n" + // line0 + 1 + "var a = 1;\n" + // line0 + 2 + "var b = 2;\n", // line0 + 3 + gDebuggee); +} diff --git a/devtools/server/tests/unit/test_listsources-02.js b/devtools/server/tests/unit/test_listsources-02.js new file mode 100644 index 000000000..190a5e31b --- /dev/null +++ b/devtools/server/tests/unit/test_listsources-02.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check getting sources before there are any. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +var gNumTimesSourcesSent = 0; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.request = (function (request) { + return function (aRequest, aOnResponse) { + if (aRequest.type === "sources") { + ++gNumTimesSourcesSent; + } + return request.call(this, aRequest, aOnResponse); + }; + }(gClient.request)); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_listing_zero_sources(); + }); + }); + do_test_pending(); +} + +function test_listing_zero_sources() +{ + gThreadClient.getSources(function (aPacket) { + do_check_true(!aPacket.error); + do_check_true(!!aPacket.sources); + do_check_eq(aPacket.sources.length, 0); + + do_check_true(gNumTimesSourcesSent <= 1, + "Should only send one sources request at most, even though we" + + " might have had to send one to determine feature support."); + + finishClient(gClient); + }); +} diff --git a/devtools/server/tests/unit/test_listsources-03.js b/devtools/server/tests/unit/test_listsources-03.js new file mode 100644 index 000000000..72ebb5e1c --- /dev/null +++ b/devtools/server/tests/unit/test_listsources-03.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check getSources functionality when there are lots of sources. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-sources"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-sources", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_listsources(); + }); + }); + do_test_pending(); +} + +function test_simple_listsources() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.getSources(function (aResponse) { + do_check_true( + !aResponse.error, + "There shouldn't be an error fetching large amounts of sources."); + + do_check_true(aResponse.sources.some(function (s) { + return s.url.match(/foo-999.js$/); + })); + + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + + for (let i = 0; i < 1000; i++) { + Cu.evalInSandbox("function foo###() {return ###;}".replace(/###/g, i), + gDebuggee, + "1.8", + "http://example.com/foo-" + i + ".js", + 1); + } + gDebuggee.eval("debugger;"); +} diff --git a/devtools/server/tests/unit/test_listsources-04.js b/devtools/server/tests/unit/test_listsources-04.js new file mode 100644 index 000000000..6da99a6ce --- /dev/null +++ b/devtools/server/tests/unit/test_listsources-04.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check getSources functionality with sourcemaps. + */ + +const {SourceNode} = require("source-map"); + +function run_test() { + run_test_with_server(DebuggerServer, function () { + // Bug 1304144 - This test does not run in a worker because the + // `rpc` method which talks to the main thread does not work. + // run_test_with_server(WorkerDebuggerServer, do_test_finished); + do_test_finished(); + }); + do_test_pending(); +} + +function run_test_with_server(server, cb) { + Task.spawn(function*() { + initTestDebuggerServer(server); + const debuggee = addTestGlobal("test-sources", server); + const client = new DebuggerClient(server.connectPipe()); + yield client.connect(); + const [,,threadClient] = yield attachTestTabAndResume(client, "test-sources"); + + yield threadClient.reconfigure({ useSourceMaps: true }); + addSources(debuggee); + + threadClient.getSources(Task.async(function* (res) { + do_check_true(res.sources.length === 3, "3 sources exist"); + + yield threadClient.reconfigure({ useSourceMaps: false }); + + threadClient.getSources(function(res) { + do_check_true(res.sources.length === 1, "1 source exist"); + client.close().then(cb); + }); + })); + }); +} + +function addSources(debuggee) { + let { code, map } = (new SourceNode(null, null, null, [ + new SourceNode(1, 0, "a.js", "function a() { return 'a'; }\n"), + new SourceNode(1, 0, "b.js", "function b() { return 'b'; }\n"), + new SourceNode(1, 0, "c.js", "function c() { return 'c'; }\n"), + ])).toStringWithSourceMap({ + file: "abc.js", + sourceRoot: "http://example.com/www/js/" + }); + + code += "//# sourceMappingURL=data:text/json;base64," + btoa(map.toString()); + + Components.utils.evalInSandbox(code, debuggee, "1.8", + "http://example.com/www/js/abc.js", 1); +} diff --git a/devtools/server/tests/unit/test_longstringactor.js b/devtools/server/tests/unit/test_longstringactor.js new file mode 100644 index 000000000..18b928910 --- /dev/null +++ b/devtools/server/tests/unit/test_longstringactor.js @@ -0,0 +1,104 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { LongStringActor } = require("devtools/server/actors/object"); + +function run_test() { + test_LSA_disconnect(); + test_LSA_grip(); + test_LSA_onSubstring(); +} + +const TEST_STRING = "This is a very long string!"; + +function makeMockLongStringActor() +{ + let string = TEST_STRING; + let actor = new LongStringActor(string); + actor.actorID = "longString1"; + actor.registeredPool = { + longStringActors: { + [string]: actor + } + }; + return actor; +} + +function test_LSA_disconnect() +{ + let actor = makeMockLongStringActor(); + do_check_eq(actor.registeredPool.longStringActors[TEST_STRING], actor); + + actor.disconnect(); + do_check_eq(actor.registeredPool.longStringActors[TEST_STRING], void 0); +} + +function test_LSA_substring() +{ + let actor = makeMockLongStringActor(); + do_check_eq(actor._substring(0, 4), TEST_STRING.substring(0, 4)); + do_check_eq(actor._substring(6, 9), TEST_STRING.substring(6, 9)); + do_check_eq(actor._substring(0, TEST_STRING.length), TEST_STRING); +} + +function test_LSA_grip() +{ + let actor = makeMockLongStringActor(); + + let grip = actor.grip(); + do_check_eq(grip.type, "longString"); + do_check_eq(grip.initial, TEST_STRING.substring(0, DebuggerServer.LONG_STRING_INITIAL_LENGTH)); + do_check_eq(grip.length, TEST_STRING.length); + do_check_eq(grip.actor, actor.actorID); +} + +function test_LSA_onSubstring() +{ + let actor = makeMockLongStringActor(); + let response; + + // From the start + response = actor.onSubstring({ + start: 0, + end: 4 + }); + do_check_eq(response.from, actor.actorID); + do_check_eq(response.substring, TEST_STRING.substring(0, 4)); + + // In the middle + response = actor.onSubstring({ + start: 5, + end: 8 + }); + do_check_eq(response.from, actor.actorID); + do_check_eq(response.substring, TEST_STRING.substring(5, 8)); + + // Whole string + response = actor.onSubstring({ + start: 0, + end: TEST_STRING.length + }); + do_check_eq(response.from, actor.actorID); + do_check_eq(response.substring, TEST_STRING); + + // Negative index + response = actor.onSubstring({ + start: -5, + end: TEST_STRING.length + }); + do_check_eq(response.from, actor.actorID); + do_check_eq(response.substring, + TEST_STRING.substring(-5, TEST_STRING.length)); + + // Past the end + response = actor.onSubstring({ + start: TEST_STRING.length - 5, + end: 100 + }); + do_check_eq(response.from, actor.actorID); + do_check_eq(response.substring, + TEST_STRING.substring(TEST_STRING.length - 5, 100)); +} diff --git a/devtools/server/tests/unit/test_longstringgrips-01.js b/devtools/server/tests/unit/test_longstringgrips-01.js new file mode 100644 index 000000000..b8e6789c7 --- /dev/null +++ b/devtools/server/tests/unit/test_longstringgrips-01.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-grips"); + gDebuggee.eval(function stopMe(arg1) { + debugger; + }.toString()); + + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_longstring_grip(); + }); + }); + do_test_pending(); +} + +function test_longstring_grip() +{ + let longString = "All I want is to be a monkey of moderate intelligence who" + + " wears a suit... that's why I'm transferring to business school! Maybe I" + + " love you so much, I love you no matter who you are pretending to be." + + " Enough about your promiscuous mother, Hermes! We have bigger problems." + + " For example, if you killed your grandfather, you'd cease to exist! What" + + " kind of a father would I be if I said no? Yep, I remember. They came in" + + " last at the Olympics, then retired to promote alcoholic beverages! And" + + " remember, don't do anything that affects anything, unless it turns out" + + " you were supposed to, in which case, for the love of God, don't not do" + + " it!"; + + DebuggerServer.LONG_STRING_LENGTH = 200; + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame.arguments; + do_check_eq(args.length, 1); + let grip = args[0]; + + try { + do_check_eq(grip.type, "longString"); + do_check_eq(grip.length, longString.length); + do_check_eq(grip.initial, longString.substr(0, DebuggerServer.LONG_STRING_INITIAL_LENGTH)); + + let longStringClient = gThreadClient.pauseLongString(grip); + longStringClient.substring(22, 28, function (aResponse) { + try { + do_check_eq(aResponse.substring, "monkey"); + } finally { + gThreadClient.resume(function () { + finishClient(gClient); + }); + } + }); + } catch (error) { + gThreadClient.resume(function () { + finishClient(gClient); + do_throw(error); + }); + } + }); + + gDebuggee.eval('stopMe("' + longString + '")'); +} + diff --git a/devtools/server/tests/unit/test_longstringgrips-02.js b/devtools/server/tests/unit/test_longstringgrips-02.js new file mode 100644 index 000000000..01f9c1b8f --- /dev/null +++ b/devtools/server/tests/unit/test_longstringgrips-02.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-grips"); + gDebuggee.eval(function stopMe(arg1) { + debugger; + }.toString()); + + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume( + gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_longstring_grip(); + }); + }); + do_test_pending(); +} + +function test_longstring_grip() +{ + DebuggerServer.LONG_STRING_LENGTH = 200; + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + try { + let fakeLongStringGrip = { + type: "longString", + length: 1000000, + actor: "123fakeActor123", + initial: "" + }; + let longStringClient = gThreadClient.pauseLongString(fakeLongStringGrip); + longStringClient.substring(22, 28, function (aResponse) { + try { + do_check_true(!!aResponse.error, + "We should not get a response, but an error."); + } finally { + gThreadClient.resume(function () { + finishClient(gClient); + }); + } + }); + } catch (error) { + gThreadClient.resume(function () { + finishClient(gClient); + do_throw(error); + }); + } + }); + + gDebuggee.eval("stopMe()"); +} + diff --git a/devtools/server/tests/unit/test_monitor_actor.js b/devtools/server/tests/unit/test_monitor_actor.js new file mode 100644 index 000000000..17c272d80 --- /dev/null +++ b/devtools/server/tests/unit/test_monitor_actor.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test the monitor actor. + */ + +"use strict"; + +function run_test() +{ + let EventEmitter = require("devtools/shared/event-emitter"); + + function MonitorClient(client, form) { + this.client = client; + this.actor = form.monitorActor; + this.events = ["update"]; + + EventEmitter.decorate(this); + client.registerClient(this); + } + MonitorClient.prototype.destroy = function () { + this.client.unregisterClient(this); + }; + MonitorClient.prototype.start = function (callback) { + this.client.request({ + to: this.actor, + type: "start" + }, callback); + }; + MonitorClient.prototype.stop = function (callback) { + this.client.request({ + to: this.actor, + type: "stop" + }, callback); + }; + + let monitor, client; + + // Start the monitor actor. + get_chrome_actors((c, form) => { + client = c; + monitor = new MonitorClient(client, form); + monitor.on("update", gotUpdate); + monitor.start(update); + }); + + let time = Date.now(); + + function update() { + let event = { + graph: "Test", + curve: "test", + value: 42, + time: time, + }; + Services.obs.notifyObservers(null, "devtools-monitor-update", JSON.stringify(event)); + } + + function gotUpdate(type, packet) { + packet.data.forEach(function (event) { + // Ignore updates that were not sent by this test. + if (event.graph === "Test") { + do_check_eq(event.curve, "test"); + do_check_eq(event.value, 42); + do_check_eq(event.time, time); + monitor.stop(function (aResponse) { + monitor.destroy(); + finishClient(client); + }); + } + }); + } + + do_test_pending(); +} diff --git a/devtools/server/tests/unit/test_nativewrappers.js b/devtools/server/tests/unit/test_nativewrappers.js new file mode 100644 index 000000000..fbadfcdec --- /dev/null +++ b/devtools/server/tests/unit/test_nativewrappers.js @@ -0,0 +1,30 @@ +function run_test() +{ + Components.utils.import("resource://gre/modules/jsdebugger.jsm"); + addDebuggerToGlobal(this); + var g = testGlobal("test1"); + + var dbg = new Debugger(); + dbg.addDebuggee(g); + dbg.onDebuggerStatement = function (aFrame) { + let args = aFrame.arguments; + try { + args[0]; + do_check_true(true); + } catch (ex) { + do_check_true(false); + } + }; + + g.eval("function stopMe(arg) {debugger;}"); + + g2 = testGlobal("test2"); + g2.g = g; + g2.eval("(" + function createBadEvent() { + let parser = Components.classes["@mozilla.org/xmlextras/domparser;1"].createInstance(Components.interfaces.nsIDOMParser); + let doc = parser.parseFromString("<foo></foo>", "text/xml"); + g.stopMe(doc.createEvent("MouseEvent")); + } + ")()"); + + dbg.enabled = false; +} diff --git a/devtools/server/tests/unit/test_nesting-01.js b/devtools/server/tests/unit/test_nesting-01.js new file mode 100644 index 000000000..e515f051e --- /dev/null +++ b/devtools/server/tests/unit/test_nesting-01.js @@ -0,0 +1,48 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that we can nest event loops when needed in +// ThreadActor.prototype.unsafeSynchronize. + +var gClient; +var gThreadActor; + +function run_test() { + initTestDebuggerServer(); + let gDebuggee = addTestGlobal("test-nesting"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-nesting", function (aResponse, aTabClient, aThreadClient) { + // Reach over the protocol connection and get a reference to the thread actor. + gThreadActor = aThreadClient._transport._serverConnection.getActor(aThreadClient._actor); + + test_nesting(); + }); + }); + do_test_pending(); +} + +function test_nesting() { + const thread = gThreadActor; + const { resolve, reject, promise: p } = promise.defer(); + + let currentStep = 0; + + executeSoon(function () { + // Should be on the first step + do_check_eq(++currentStep, 1); + // We should have one nested event loop from unsfeSynchronize + do_check_eq(thread._nestedEventLoops.size, 1); + resolve(true); + }); + + do_check_eq(thread.unsafeSynchronize(p), true); + + // Should be on the second step + do_check_eq(++currentStep, 2); + // There shouldn't be any nested event loops anymore + do_check_eq(thread._nestedEventLoops.size, 0); + + finishClient(gClient); +} diff --git a/devtools/server/tests/unit/test_nesting-02.js b/devtools/server/tests/unit/test_nesting-02.js new file mode 100644 index 000000000..928331be5 --- /dev/null +++ b/devtools/server/tests/unit/test_nesting-02.js @@ -0,0 +1,81 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that we can nest event loops and then automatically exit nested event +// loops when requested. + +var gClient; +var gThreadActor; + +function run_test() { + initTestDebuggerServer(); + let gDebuggee = addTestGlobal("test-nesting"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-nesting", function (aResponse, aTabClient, aThreadClient) { + // Reach over the protocol connection and get a reference to the thread + // actor. + gThreadActor = aThreadClient._transport._serverConnection.getActor(aThreadClient._actor); + + test_nesting(); + }); + }); + do_test_pending(); +} + +function test_nesting() { + const thread = gThreadActor; + const { resolve, reject, promise: p } = promise.defer(); + + // The following things should happen (in order): + // 1. In the new event loop (created by unsafeSynchronize) + // 2. Resolve the promise (shouldn't exit any event loops) + // 3. Exit the event loop (should also then exit unsafeSynchronize's event loop) + // 4. Be after the unsafeSynchronize call + let currentStep = 0; + + executeSoon(function () { + let eventLoop; + + executeSoon(function () { + // Should be at step 2 + do_check_eq(++currentStep, 2); + // Before resolving, should have the unsafeSynchronize event loop and the + // one just created. + do_check_eq(thread._nestedEventLoops.size, 2); + + executeSoon(function () { + // Should be at step 3 + do_check_eq(++currentStep, 3); + // Before exiting the manually created event loop, should have the + // unsafeSynchronize event loop and the manual event loop. + do_check_eq(thread._nestedEventLoops.size, 2); + // Should have the event loop + do_check_true(!!eventLoop); + eventLoop.resolve(); + }); + + resolve(true); + // Shouldn't exit any event loops because a new one started since the call + // to unsafeSynchronize + do_check_eq(thread._nestedEventLoops.size, 2); + }); + + // Should be at step 1 + do_check_eq(++currentStep, 1); + // Should have only the unsafeSynchronize event loop + do_check_eq(thread._nestedEventLoops.size, 1); + eventLoop = thread._nestedEventLoops.push(); + eventLoop.enter(); + }); + + do_check_eq(thread.unsafeSynchronize(p), true); + + // Should be on the fourth step + do_check_eq(++currentStep, 4); + // There shouldn't be any nested event loops anymore + do_check_eq(thread._nestedEventLoops.size, 0); + + finishClient(gClient); +} diff --git a/devtools/server/tests/unit/test_nesting-03.js b/devtools/server/tests/unit/test_nesting-03.js new file mode 100644 index 000000000..6a0e5a66b --- /dev/null +++ b/devtools/server/tests/unit/test_nesting-03.js @@ -0,0 +1,51 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that we can detect nested event loops in tabs with the same URL. + +var gClient1, gClient2, gThreadClient1, gThreadClient2; + +function run_test() { + initTestDebuggerServer(); + addTestGlobal("test-nesting1"); + addTestGlobal("test-nesting1"); + // Conect the first client to the first debuggee. + gClient1 = new DebuggerClient(DebuggerServer.connectPipe()); + gClient1.connect(function () { + attachTestThread(gClient1, "test-nesting1", function (aResponse, aTabClient, aThreadClient) { + gThreadClient1 = aThreadClient; + start_second_connection(); + }); + }); + do_test_pending(); +} + +function start_second_connection() { + gClient2 = new DebuggerClient(DebuggerServer.connectPipe()); + gClient2.connect(function () { + attachTestThread(gClient2, "test-nesting1", function (aResponse, aTabClient, aThreadClient) { + gThreadClient2 = aThreadClient; + test_nesting(); + }); + }); +} + +function test_nesting() { + const { resolve, reject, promise: p } = promise.defer(); + + gThreadClient1.resume(aResponse => { + do_check_eq(aResponse.error, "wrongOrder"); + gThreadClient2.resume(aResponse => { + do_check_true(!aResponse.error); + do_check_eq(aResponse.from, gThreadClient2.actor); + + gThreadClient1.resume(aResponse => { + do_check_true(!aResponse.error); + do_check_eq(aResponse.from, gThreadClient1.actor); + + gClient1.close(() => finishClient(gClient2)); + }); + }); + }); +} diff --git a/devtools/server/tests/unit/test_new_source-01.js b/devtools/server/tests/unit/test_new_source-01.js new file mode 100644 index 000000000..aa2498371 --- /dev/null +++ b/devtools/server/tests/unit/test_new_source-01.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check basic newSource packet sent from server. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_new_source(); + }); + }); + do_test_pending(); +} + +function test_simple_new_source() +{ + gThreadClient.addOneTimeListener("newSource", function (aEvent, aPacket) { + do_check_eq(aEvent, "newSource"); + do_check_eq(aPacket.type, "newSource"); + do_check_true(!!aPacket.source); + do_check_true(!!aPacket.source.url.match(/test_new_source-01.js$/)); + + finishClient(gClient); + }); + + Components.utils.evalInSandbox(function inc(n) { + return n + 1; + }.toString(), gDebuggee); +} diff --git a/devtools/server/tests/unit/test_nodelistactor.js b/devtools/server/tests/unit/test_nodelistactor.js new file mode 100644 index 000000000..4d9ec1a7a --- /dev/null +++ b/devtools/server/tests/unit/test_nodelistactor.js @@ -0,0 +1,26 @@ +/* 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 a NodeListActor initialized with null nodelist doesn't cause +// exceptions when calling NodeListActor.form. + +const { NodeListActor } = require("devtools/server/actors/inspector"); + +function run_test() { + check_actor_for_list(null); + check_actor_for_list([]); + check_actor_for_list(["fakenode"]); +} + +function check_actor_for_list(nodelist) { + do_print("Checking NodeListActor with nodelist '" + nodelist + "' works."); + let actor = new NodeListActor({}, nodelist); + let form = actor.form(); + + // No exception occured as a exceptions abort the test. + ok(true, "No exceptions occured."); + equal(form.length, nodelist ? nodelist.length : 0, + "NodeListActor reported correct length."); +} diff --git a/devtools/server/tests/unit/test_nsjsinspector.js b/devtools/server/tests/unit/test_nsjsinspector.js new file mode 100644 index 000000000..14a99a308 --- /dev/null +++ b/devtools/server/tests/unit/test_nsjsinspector.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test the basic functionality of the nsIJSInspector component. +var gCount = 0; +const MAX = 10; +var inspector = Cc["@mozilla.org/jsinspector;1"].getService(Ci.nsIJSInspector); +var tm = Cc["@mozilla.org/thread-manager;1"].getService(Ci.nsIThreadManager); + +// Emulate 10 simultaneously-debugged windows from 3 separate client connections. +var requestor = (count) => ({ + url:"http://foo/bar/" + count, + connection: "conn" + (count % 3) +}); + +function run_test() +{ + test_nesting(); +} + +function test_nesting() +{ + do_check_eq(inspector.eventLoopNestLevel, 0); + + tm.currentThread.dispatch({ run: enterEventLoop}, 0); + + do_check_eq(inspector.enterNestedEventLoop(requestor(gCount)), 0); + do_check_eq(inspector.eventLoopNestLevel, 0); + do_check_eq(inspector.lastNestRequestor, null); +} + +function enterEventLoop() { + if (gCount++ < MAX) { + tm.currentThread.dispatch({ run: enterEventLoop}, 0); + + let r = Object.create(requestor(gCount)); + + do_check_eq(inspector.eventLoopNestLevel, gCount); + do_check_eq(inspector.lastNestRequestor.url, requestor(gCount - 1).url); + do_check_eq(inspector.lastNestRequestor.connection, requestor(gCount - 1).connection); + do_check_eq(inspector.enterNestedEventLoop(requestor(gCount)), gCount); + } else { + do_check_eq(gCount, MAX + 1); + tm.currentThread.dispatch({ run: exitEventLoop}, 0); + } +} + +function exitEventLoop() { + if (inspector.lastNestRequestor != null) { + do_check_eq(inspector.lastNestRequestor.url, requestor(gCount - 1).url); + do_check_eq(inspector.lastNestRequestor.connection, requestor(gCount - 1).connection); + if (gCount-- > 1) { + tm.currentThread.dispatch({ run: exitEventLoop}, 0); + } + + do_check_eq(inspector.exitNestedEventLoop(), gCount); + do_check_eq(inspector.eventLoopNestLevel, gCount); + } +} diff --git a/devtools/server/tests/unit/test_objectgrips-01.js b/devtools/server/tests/unit/test_objectgrips-01.js new file mode 100644 index 000000000..e1857e5b8 --- /dev/null +++ b/devtools/server/tests/unit/test_objectgrips-01.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-grips", aServer); + gDebuggee.eval(function stopMe(arg1) { + debugger; + }.toString()); + + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_object_grip(); + }); + }); +} + +function test_object_grip() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame.arguments; + + do_check_eq(args[0].class, "Object"); + + let objClient = gThreadClient.pauseGrip(args[0]); + objClient.getOwnPropertyNames(function (aResponse) { + do_check_eq(aResponse.ownPropertyNames.length, 3); + do_check_eq(aResponse.ownPropertyNames[0], "a"); + do_check_eq(aResponse.ownPropertyNames[1], "b"); + do_check_eq(aResponse.ownPropertyNames[2], "c"); + + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + + }); + + gDebuggee.eval("stopMe({ a: 1, b: true, c: 'foo' })"); +} + diff --git a/devtools/server/tests/unit/test_objectgrips-02.js b/devtools/server/tests/unit/test_objectgrips-02.js new file mode 100644 index 000000000..649d52c64 --- /dev/null +++ b/devtools/server/tests/unit/test_objectgrips-02.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-grips", aServer); + gDebuggee.eval(function stopMe(arg1) { + debugger; + }.toString()); + + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_object_grip(); + }); + }); +} + +function test_object_grip() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame.arguments; + + do_check_eq(args[0].class, "Object"); + + let objClient = gThreadClient.pauseGrip(args[0]); + objClient.getPrototype(function (aResponse) { + do_check_true(aResponse.prototype != undefined); + + let protoClient = gThreadClient.pauseGrip(aResponse.prototype); + protoClient.getOwnPropertyNames(function (aResponse) { + do_check_eq(aResponse.ownPropertyNames.length, 2); + do_check_eq(aResponse.ownPropertyNames[0], "b"); + do_check_eq(aResponse.ownPropertyNames[1], "c"); + + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + }); + + }); + + gDebuggee.eval(function Constr() { + this.a = 1; + }.toString()); + gDebuggee.eval("Constr.prototype = { b: true, c: 'foo' }; var o = new Constr(); stopMe(o)"); +} + diff --git a/devtools/server/tests/unit/test_objectgrips-03.js b/devtools/server/tests/unit/test_objectgrips-03.js new file mode 100644 index 000000000..8b19db713 --- /dev/null +++ b/devtools/server/tests/unit/test_objectgrips-03.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-grips", aServer); + gDebuggee.eval(function stopMe(arg1) { + debugger; + }.toString()); + + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_object_grip(); + }); + }); +} + +function test_object_grip() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame.arguments; + + do_check_eq(args[0].class, "Object"); + + let objClient = gThreadClient.pauseGrip(args[0]); + objClient.getProperty("x", function (aResponse) { + do_check_eq(aResponse.descriptor.configurable, true); + do_check_eq(aResponse.descriptor.enumerable, true); + do_check_eq(aResponse.descriptor.writable, true); + do_check_eq(aResponse.descriptor.value, 10); + + objClient.getProperty("y", function (aResponse) { + do_check_eq(aResponse.descriptor.configurable, true); + do_check_eq(aResponse.descriptor.enumerable, true); + do_check_eq(aResponse.descriptor.writable, true); + do_check_eq(aResponse.descriptor.value, "kaiju"); + + objClient.getProperty("a", function (aResponse) { + do_check_eq(aResponse.descriptor.configurable, true); + do_check_eq(aResponse.descriptor.enumerable, true); + do_check_eq(aResponse.descriptor.get.type, "object"); + do_check_eq(aResponse.descriptor.get.class, "Function"); + do_check_eq(aResponse.descriptor.set.type, "undefined"); + + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + }); + }); + + }); + + gDebuggee.eval("stopMe({ x: 10, y: 'kaiju', get a() { return 42; } })"); +} + diff --git a/devtools/server/tests/unit/test_objectgrips-04.js b/devtools/server/tests/unit/test_objectgrips-04.js new file mode 100644 index 000000000..1662358e0 --- /dev/null +++ b/devtools/server/tests/unit/test_objectgrips-04.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-grips", aServer); + gDebuggee.eval(function stopMe(arg1) { + debugger; + }.toString()); + + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_object_grip(); + }); + }); +} + +function test_object_grip() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame.arguments; + + do_check_eq(args[0].class, "Object"); + + let objClient = gThreadClient.pauseGrip(args[0]); + objClient.getPrototypeAndProperties(function (aResponse) { + do_check_eq(aResponse.ownProperties.x.configurable, true); + do_check_eq(aResponse.ownProperties.x.enumerable, true); + do_check_eq(aResponse.ownProperties.x.writable, true); + do_check_eq(aResponse.ownProperties.x.value, 10); + + do_check_eq(aResponse.ownProperties.y.configurable, true); + do_check_eq(aResponse.ownProperties.y.enumerable, true); + do_check_eq(aResponse.ownProperties.y.writable, true); + do_check_eq(aResponse.ownProperties.y.value, "kaiju"); + + do_check_eq(aResponse.ownProperties.a.configurable, true); + do_check_eq(aResponse.ownProperties.a.enumerable, true); + do_check_eq(aResponse.ownProperties.a.get.type, "object"); + do_check_eq(aResponse.ownProperties.a.get.class, "Function"); + do_check_eq(aResponse.ownProperties.a.set.type, "undefined"); + + do_check_true(aResponse.prototype != undefined); + + let protoClient = gThreadClient.pauseGrip(aResponse.prototype); + protoClient.getOwnPropertyNames(function (aResponse) { + do_check_true(aResponse.ownPropertyNames.toString != undefined); + + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + }); + + }); + + gDebuggee.eval("stopMe({ x: 10, y: 'kaiju', get a() { return 42; } })"); +} + diff --git a/devtools/server/tests/unit/test_objectgrips-05.js b/devtools/server/tests/unit/test_objectgrips-05.js new file mode 100644 index 000000000..5bbb37d88 --- /dev/null +++ b/devtools/server/tests/unit/test_objectgrips-05.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test checks that frozen objects report themselves as frozen in their + * grip. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-grips", aServer); + gDebuggee.eval(function stopMe(arg1, arg2) { + debugger; + }.toString()); + + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_object_grip(); + }); + }); +} + +function test_object_grip() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let obj1 = aPacket.frame.arguments[0]; + do_check_true(obj1.frozen); + + let obj1Client = gThreadClient.pauseGrip(obj1); + do_check_true(obj1Client.isFrozen); + + let obj2 = aPacket.frame.arguments[1]; + do_check_false(obj2.frozen); + + let obj2Client = gThreadClient.pauseGrip(obj2); + do_check_false(obj2Client.isFrozen); + + gThreadClient.resume(_ => { + gClient.close().then(gCallback); + }); + }); + + gDebuggee.eval("(" + function () { + let obj1 = {}; + Object.freeze(obj1); + stopMe(obj1, {}); + } + "())"); +} + diff --git a/devtools/server/tests/unit/test_objectgrips-06.js b/devtools/server/tests/unit/test_objectgrips-06.js new file mode 100644 index 000000000..bb9888ab8 --- /dev/null +++ b/devtools/server/tests/unit/test_objectgrips-06.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test checks that sealed objects report themselves as sealed in their + * grip. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-grips", aServer); + gDebuggee.eval(function stopMe(arg1, arg2) { + debugger; + }.toString()); + + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_object_grip(); + }); + }); +} + +function test_object_grip() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let obj1 = aPacket.frame.arguments[0]; + do_check_true(obj1.sealed); + + let obj1Client = gThreadClient.pauseGrip(obj1); + do_check_true(obj1Client.isSealed); + + let obj2 = aPacket.frame.arguments[1]; + do_check_false(obj2.sealed); + + let obj2Client = gThreadClient.pauseGrip(obj2); + do_check_false(obj2Client.isSealed); + + gThreadClient.resume(_ => { + gClient.close().then(gCallback); + }); + }); + + gDebuggee.eval("(" + function () { + let obj1 = {}; + Object.seal(obj1); + stopMe(obj1, {}); + } + "())"); +} + diff --git a/devtools/server/tests/unit/test_objectgrips-07.js b/devtools/server/tests/unit/test_objectgrips-07.js new file mode 100644 index 000000000..6d9ac11fb --- /dev/null +++ b/devtools/server/tests/unit/test_objectgrips-07.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test checks that objects which are not extensible report themselves as + * such. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-grips", aServer); + gDebuggee.eval(function stopMe(arg1, arg2, arg3, arg4) { + debugger; + }.toString()); + + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_object_grip(); + }); + }); +} + +function test_object_grip() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let [f, s, ne, e] = aPacket.frame.arguments; + let [fClient, sClient, neClient, eClient] = aPacket.frame.arguments.map( + a => gThreadClient.pauseGrip(a)); + + do_check_false(f.extensible); + do_check_false(fClient.isExtensible); + + do_check_false(s.extensible); + do_check_false(sClient.isExtensible); + + do_check_false(ne.extensible); + do_check_false(neClient.isExtensible); + + do_check_true(e.extensible); + do_check_true(eClient.isExtensible); + + gThreadClient.resume(_ => { + gClient.close().then(gCallback); + }); + }); + + gDebuggee.eval("(" + function () { + let f = {}; + Object.freeze(f); + let s = {}; + Object.seal(s); + let ne = {}; + Object.preventExtensions(ne); + stopMe(f, s, ne, {}); + } + "())"); +} + diff --git a/devtools/server/tests/unit/test_objectgrips-08.js b/devtools/server/tests/unit/test_objectgrips-08.js new file mode 100644 index 000000000..ecaa7146d --- /dev/null +++ b/devtools/server/tests/unit/test_objectgrips-08.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-grips", aServer); + gDebuggee.eval(function stopMe(arg1) { + debugger; + }.toString()); + + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_object_grip(); + }); + }); +} + +function test_object_grip() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame.arguments; + + do_check_eq(args[0].class, "Object"); + + let objClient = gThreadClient.pauseGrip(args[0]); + objClient.getPrototypeAndProperties(function (aResponse) { + do_check_eq(aResponse.ownProperties.a.configurable, true); + do_check_eq(aResponse.ownProperties.a.enumerable, true); + do_check_eq(aResponse.ownProperties.a.writable, true); + do_check_eq(aResponse.ownProperties.a.value.type, "Infinity"); + + do_check_eq(aResponse.ownProperties.b.configurable, true); + do_check_eq(aResponse.ownProperties.b.enumerable, true); + do_check_eq(aResponse.ownProperties.b.writable, true); + do_check_eq(aResponse.ownProperties.b.value.type, "-Infinity"); + + do_check_eq(aResponse.ownProperties.c.configurable, true); + do_check_eq(aResponse.ownProperties.c.enumerable, true); + do_check_eq(aResponse.ownProperties.c.writable, true); + do_check_eq(aResponse.ownProperties.c.value.type, "NaN"); + + do_check_eq(aResponse.ownProperties.d.configurable, true); + do_check_eq(aResponse.ownProperties.d.enumerable, true); + do_check_eq(aResponse.ownProperties.d.writable, true); + do_check_eq(aResponse.ownProperties.d.value.type, "-0"); + + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + }); + + gDebuggee.eval("stopMe({ a: Infinity, b: -Infinity, c: NaN, d: -0 })"); +} + diff --git a/devtools/server/tests/unit/test_objectgrips-09.js b/devtools/server/tests/unit/test_objectgrips-09.js new file mode 100644 index 000000000..498154b1e --- /dev/null +++ b/devtools/server/tests/unit/test_objectgrips-09.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/** + * This tests exercises getProtypesAndProperties message accepted + * by a thread actor. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-grips", aServer); + gDebuggee.eval(function stopMe(arg1, arg2) { + debugger; + }.toString()); + + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_object_grip(); + }); + }); +} + +function test_object_grip() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame.arguments; + + gThreadClient.getPrototypesAndProperties([args[0].actor, args[1].actor], function (aResponse) { + let obj1 = aResponse.actors[args[0].actor]; + let obj2 = aResponse.actors[args[1].actor]; + do_check_eq(obj1.ownProperties.x.configurable, true); + do_check_eq(obj1.ownProperties.x.enumerable, true); + do_check_eq(obj1.ownProperties.x.writable, true); + do_check_eq(obj1.ownProperties.x.value, 10); + + do_check_eq(obj1.ownProperties.y.configurable, true); + do_check_eq(obj1.ownProperties.y.enumerable, true); + do_check_eq(obj1.ownProperties.y.writable, true); + do_check_eq(obj1.ownProperties.y.value, "kaiju"); + + do_check_eq(obj2.ownProperties.z.configurable, true); + do_check_eq(obj2.ownProperties.z.enumerable, true); + do_check_eq(obj2.ownProperties.z.writable, true); + do_check_eq(obj2.ownProperties.z.value, 123); + + do_check_true(obj1.prototype != undefined); + do_check_true(obj2.prototype != undefined); + + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + + }); + + gDebuggee.eval("stopMe({ x: 10, y: 'kaiju'}, { z: 123 })"); +} + diff --git a/devtools/server/tests/unit/test_objectgrips-10.js b/devtools/server/tests/unit/test_objectgrips-10.js new file mode 100644 index 000000000..a5d1b18c6 --- /dev/null +++ b/devtools/server/tests/unit/test_objectgrips-10.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gDebuggee; +var gClient; +var gThreadClient; + +// Test that closures can be inspected. + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-closures"); + + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-closures", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_object_grip(); + }); + }); + do_test_pending(); +} + +function test_object_grip() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let person = aPacket.frame.environment.bindings.variables.person; + + do_check_eq(person.value.class, "Object"); + + let personClient = gThreadClient.pauseGrip(person.value); + personClient.getPrototypeAndProperties(aResponse => { + do_check_eq(aResponse.ownProperties.getName.value.class, "Function"); + + do_check_eq(aResponse.ownProperties.getAge.value.class, "Function"); + + do_check_eq(aResponse.ownProperties.getFoo.value.class, "Function"); + + let getNameClient = gThreadClient.pauseGrip(aResponse.ownProperties.getName.value); + let getAgeClient = gThreadClient.pauseGrip(aResponse.ownProperties.getAge.value); + let getFooClient = gThreadClient.pauseGrip(aResponse.ownProperties.getFoo.value); + getNameClient.getScope(aResponse => { + do_check_eq(aResponse.scope.bindings.arguments[0].name.value, "Bob"); + + getAgeClient.getScope(aResponse => { + do_check_eq(aResponse.scope.bindings.arguments[1].age.value, 58); + + getFooClient.getScope(aResponse => { + do_check_eq(aResponse.scope.bindings.variables.foo.value, 10); + + gThreadClient.resume(() => finishClient(gClient)); + }); + }); + }); + }); + + }); + + gDebuggee.eval("(" + function () { + var PersonFactory = function (name, age) { + var foo = 10; + return { + getName: function () { return name; }, + getAge: function () { return age; }, + getFoo: function () { foo = Date.now(); return foo; } + }; + }; + var person = new PersonFactory("Bob", 58); + debugger; + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_objectgrips-11.js b/devtools/server/tests/unit/test_objectgrips-11.js new file mode 100644 index 000000000..1ad5c353a --- /dev/null +++ b/devtools/server/tests/unit/test_objectgrips-11.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that we get the magic properties on Error objects. + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-grips"); + gDebuggee.eval(function stopMe(arg1) { + debugger; + }.toString()); + + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_object_grip(); + }); + }); + do_test_pending(); +} + +function test_object_grip() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame.arguments; + + let objClient = gThreadClient.pauseGrip(args[0]); + objClient.getOwnPropertyNames(function (aResponse) { + var opn = aResponse.ownPropertyNames; + do_check_eq(opn.length, 4); + opn.sort(); + do_check_eq(opn[0], "columnNumber"); + do_check_eq(opn[1], "fileName"); + do_check_eq(opn[2], "lineNumber"); + do_check_eq(opn[3], "message"); + + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + + }); + + gDebuggee.eval("stopMe(new TypeError('error message text'))"); +} + diff --git a/devtools/server/tests/unit/test_objectgrips-12.js b/devtools/server/tests/unit/test_objectgrips-12.js new file mode 100644 index 000000000..32d4d47e0 --- /dev/null +++ b/devtools/server/tests/unit/test_objectgrips-12.js @@ -0,0 +1,162 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test getDisplayString. + +Cu.import("resource://testing-common/PromiseTestUtils.jsm", this); + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-grips"); + gDebuggee.eval(function stopMe(arg1) { + debugger; + }.toString()); + + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_display_string(); + }); + }); + do_test_pending(); +} + +function test_display_string() +{ + const testCases = [ + { + input: "new Boolean(true)", + output: "true" + }, + { + input: "new Number(5)", + output: "5" + }, + { + input: "new String('foo')", + output: "foo" + }, + { + input: "new Map()", + output: "[object Map]" + }, + { + input: "[,,,,,,,]", + output: ",,,,,," + }, + { + input: "[1, 2, 3]", + output: "1,2,3" + }, + { + input: "[undefined, null, true, 'foo', 5]", + output: ",,true,foo,5" + }, + { + input: "[{},{}]", + output: "[object Object],[object Object]" + }, + { + input: "(" + function () { + const arr = [1]; + arr.push(arr); + return arr; + } + ")()", + output: "1," + }, + { + input: "{}", + output: "[object Object]" + }, + { + input: "Object.create(null)", + output: "[object Object]" + }, + { + input: "new Error('foo')", + output: "Error: foo" + }, + { + input: "new SyntaxError()", + output: "SyntaxError" + }, + { + input: "new ReferenceError('')", + output: "ReferenceError" + }, + { + input: "(" + function () { + const err = new Error("bar"); + err.name = "foo"; + return err; + } + ")()", + output: "foo: bar" + }, + { + input: "() => {}", + output: "() => {}" + }, + { + input: "function (foo, bar) {}", + output: "function (foo, bar) {}" + }, + { + input: "function foo(bar) {}", + output: "function foo(bar) {}" + }, + { + input: "Array", + output: Array + "" + }, + { + input: "/foo[bar]/g", + output: "/foo[bar]/g" + }, + { + input: "new Proxy({}, {})", + output: "[object Object]" + }, + { + input: "Promise.resolve(5)", + output: "Promise (fulfilled: 5)" + }, + { + // This rejection is left uncaught, see expectUncaughtRejection below. + input: "Promise.reject(new Error())", + output: "Promise (rejected: Error)" + }, + { + input: "new Promise(function () {})", + output: "Promise (pending)" + } + ]; + + PromiseTestUtils.expectUncaughtRejection(/Error/); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + const args = aPacket.frame.arguments; + + (function loop() { + const objClient = gThreadClient.pauseGrip(args.pop()); + objClient.getDisplayString(function ({ displayString }) { + do_check_eq(displayString, testCases.pop().output); + if (args.length) { + loop(); + } else { + gThreadClient.resume(function () { + finishClient(gClient); + }); + } + }); + })(); + }); + + const inputs = testCases.map(({ input }) => input).join(","); + gDebuggee.eval("stopMe(" + inputs + ")"); +} diff --git a/devtools/server/tests/unit/test_objectgrips-13.js b/devtools/server/tests/unit/test_objectgrips-13.js new file mode 100644 index 000000000..166e8a0d5 --- /dev/null +++ b/devtools/server/tests/unit/test_objectgrips-13.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that ObjectClient.prototype.getDefinitionSite and the "definitionSite" +// request work properly. + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-grips"); + Components.utils.evalInSandbox(function stopMe() { + debugger; + }.toString(), gDebuggee); + + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + add_pause_listener(); + }); + }); + do_test_pending(); +} + +function add_pause_listener() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + const [funcGrip, objGrip] = aPacket.frame.arguments; + const func = gThreadClient.pauseGrip(funcGrip); + const obj = gThreadClient.pauseGrip(objGrip); + test_definition_site(func, obj); + }); + + eval_code(); +} + +function eval_code() { + Components.utils.evalInSandbox([ + "this.line0 = Error().lineNumber;", + "function f() {}", + "stopMe(f, {});" + ].join("\n"), gDebuggee); +} + +function test_definition_site(func, obj) { + func.getDefinitionSite(({ error, source, line, column }) => { + do_check_true(!error); + do_check_eq(source.url, getFilePath("test_objectgrips-13.js")); + do_check_eq(line, gDebuggee.line0 + 1); + do_check_eq(column, 0); + + test_bad_definition_site(obj); + }); +} + +function test_bad_definition_site(obj) { + try { + obj._client.request("definitionSite", () => do_check_true(false)); + } catch (e) { + gThreadClient.resume(() => finishClient(gClient)); + } +} diff --git a/devtools/server/tests/unit/test_pause_exceptions-01.js b/devtools/server/tests/unit/test_pause_exceptions-01.js new file mode 100644 index 000000000..56ee6816d --- /dev/null +++ b/devtools/server/tests/unit/test_pause_exceptions-01.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that setting pauseOnExceptions to true will cause the debuggee to pause + * when an exceptions is thrown. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aPacket.why.type, "exception"); + do_check_eq(aPacket.why.exception, 42); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + gThreadClient.pauseOnExceptions(true); + gThreadClient.resume(); + }); + + gDebuggee.eval("(" + function () { + function stopMe() { + debugger; + throw 42; + } + try { + stopMe(); + } catch (e) {} + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_pause_exceptions-02.js b/devtools/server/tests/unit/test_pause_exceptions-02.js new file mode 100644 index 000000000..fa9b419f0 --- /dev/null +++ b/devtools/server/tests/unit/test_pause_exceptions-02.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that setting pauseOnExceptions to true when the debugger isn't in a + * paused state will cause the debuggee to pause when an exceptions is thrown. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.pauseOnExceptions(true, false, function () { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aPacket.why.type, "exception"); + do_check_eq(aPacket.why.exception, 42); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe() { + throw 42; + } + try { + stopMe(); + } catch (e) {} + } + ")()"); + }); +} diff --git a/devtools/server/tests/unit/test_pauselifetime-01.js b/devtools/server/tests/unit/test_pauselifetime-01.js new file mode 100644 index 000000000..71c2ddae7 --- /dev/null +++ b/devtools/server/tests/unit/test_pauselifetime-01.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that pause-lifetime grips go away correctly after a resume. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let pauseActor = aPacket.actor; + + // Make a bogus request to the pause-liftime actor. Should get + // unrecognized-packet-type (and not no-such-actor). + gClient.request({ to: pauseActor, type: "bogusRequest" }, function (aResponse) { + do_check_eq(aResponse.error, "unrecognizedPacketType"); + + gThreadClient.resume(function () { + // Now that we've resumed, should get no-such-actor for the + // same request. + gClient.request({ to: pauseActor, type: "bogusRequest" }, function (aResponse) { + do_check_eq(aResponse.error, "noSuchActor"); + finishClient(gClient); + }); + }); + + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe() { + debugger; + } + stopMe(); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_pauselifetime-02.js b/devtools/server/tests/unit/test_pauselifetime-02.js new file mode 100644 index 000000000..6c90725bb --- /dev/null +++ b/devtools/server/tests/unit/test_pauselifetime-02.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that pause-lifetime grips go away correctly after a resume. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame.arguments; + let objActor = args[0].actor; + do_check_eq(args[0].class, "Object"); + do_check_true(!!objActor); + + // Make a bogus request to the grip actor. Should get + // unrecognized-packet-type (and not no-such-actor). + gClient.request({ to: objActor, type: "bogusRequest" }, function (aResponse) { + do_check_eq(aResponse.error, "unrecognizedPacketType"); + + gThreadClient.resume(function () { + // Now that we've resumed, should get no-such-actor for the + // same request. + gClient.request({ to: objActor, type: "bogusRequest" }, function (aResponse) { + do_check_eq(aResponse.error, "noSuchActor"); + finishClient(gClient); + }); + }); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe(aObject) { + debugger; + } + stopMe({ foo: "bar" }); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_pauselifetime-03.js b/devtools/server/tests/unit/test_pauselifetime-03.js new file mode 100644 index 000000000..9fca887b7 --- /dev/null +++ b/devtools/server/tests/unit/test_pauselifetime-03.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that pause-lifetime grip clients are marked invalid after a resume. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame.arguments; + let objActor = args[0].actor; + do_check_eq(args[0].class, "Object"); + do_check_true(!!objActor); + + let objClient = gThreadClient.pauseGrip(args[0]); + do_check_true(objClient.valid); + + // Make a bogus request to the grip actor. Should get + // unrecognized-packet-type (and not no-such-actor). + gClient.request({ to: objActor, type: "bogusRequest" }, function (aResponse) { + do_check_eq(aResponse.error, "unrecognizedPacketType"); + do_check_true(objClient.valid); + + gThreadClient.resume(function () { + // Now that we've resumed, should get no-such-actor for the + // same request. + gClient.request({ to: objActor, type: "bogusRequest" }, function (aResponse) { + do_check_false(objClient.valid); + do_check_eq(aResponse.error, "noSuchActor"); + finishClient(gClient); + }); + }); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe(aObject) { + debugger; + } + stopMe({ foo: "bar" }); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_pauselifetime-04.js b/devtools/server/tests/unit/test_pauselifetime-04.js new file mode 100644 index 000000000..c863da921 --- /dev/null +++ b/devtools/server/tests/unit/test_pauselifetime-04.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that requesting a pause actor for the same value multiple + * times returns the same actor. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-stack"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_pause_frame(); + }); + }); + do_test_pending(); +} + +function test_pause_frame() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let args = aPacket.frame.arguments; + let objActor1 = args[0].actor; + + gThreadClient.getFrames(0, 1, function (aResponse) { + let frame = aResponse.frames[0]; + do_check_eq(objActor1, frame.arguments[0].actor); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe(aObject) { + debugger; + } + stopMe({ foo: "bar" }); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_profiler_activation-01.js b/devtools/server/tests/unit/test_profiler_activation-01.js new file mode 100644 index 000000000..31efbb5e3 --- /dev/null +++ b/devtools/server/tests/unit/test_profiler_activation-01.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests whether the profiler module and actor have the correct state on + * initialization, activation, and when a clients' connection closes. + */ + +const Profiler = Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler); +const MAX_PROFILER_ENTRIES = 10000000; + +function run_test() +{ + // Ensure the profiler is not running when the test starts (it could + // happen if the MOZ_PROFILER_STARTUP environment variable is set). + Profiler.StopProfiler(); + + get_chrome_actors((client1, form1) => { + let actor1 = form1.profilerActor; + get_chrome_actors((client2, form2) => { + let actor2 = form2.profilerActor; + test_activate(client1, actor1, client2, actor2, () => { + do_test_finished(); + }); + }); + }); + + do_test_pending(); +} + +function test_activate(client1, actor1, client2, actor2, callback) { + // Profiler should be inactive at this point. + client1.request({ to: actor1, type: "isActive" }, response => { + do_check_false(Profiler.IsActive()); + do_check_false(response.isActive); + do_check_eq(response.currentTime, undefined); + do_check_true(typeof response.position === "number"); + do_check_true(typeof response.totalSize === "number"); + do_check_true(typeof response.generation === "number"); + + // Start the profiler on the first connection.... + client1.request({ to: actor1, type: "startProfiler", entries: MAX_PROFILER_ENTRIES }, response => { + do_check_true(Profiler.IsActive()); + do_check_true(response.started); + do_check_true(typeof response.position === "number"); + do_check_true(typeof response.totalSize === "number"); + do_check_true(typeof response.generation === "number"); + do_check_true(response.position >= 0 && response.position < response.totalSize); + do_check_true(response.totalSize === MAX_PROFILER_ENTRIES); + + // On the next connection just make sure the actor has been instantiated. + client2.request({ to: actor2, type: "isActive" }, response => { + do_check_true(Profiler.IsActive()); + do_check_true(response.isActive); + do_check_true(response.currentTime > 0); + do_check_true(typeof response.position === "number"); + do_check_true(typeof response.totalSize === "number"); + do_check_true(typeof response.generation === "number"); + do_check_true(response.position >= 0 && response.position < response.totalSize); + do_check_true(response.totalSize === MAX_PROFILER_ENTRIES); + + let origConnectionClosed = DebuggerServer._connectionClosed; + + DebuggerServer._connectionClosed = function (conn) { + origConnectionClosed.call(this, conn); + + // The first client is the only actor that started the profiler, + // however the second client can request the accumulated profile data + // at any moment, so the profiler module shouldn't have deactivated. + do_check_true(Profiler.IsActive()); + + DebuggerServer._connectionClosed = function (conn) { + origConnectionClosed.call(this, conn); + + // Now there are no open clients at all, it should *definitely* + // be deactivated by now. + do_check_false(Profiler.IsActive()); + + callback(); + }; + client2.close(); + }; + client1.close(); + }); + }); + }); +} diff --git a/devtools/server/tests/unit/test_profiler_activation-02.js b/devtools/server/tests/unit/test_profiler_activation-02.js new file mode 100644 index 000000000..cf06b1e06 --- /dev/null +++ b/devtools/server/tests/unit/test_profiler_activation-02.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests whether the profiler actor correctly handles the case where the + * built-in module was already started. + */ + +const Profiler = Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler); +const WAIT_TIME = 1000; // ms + +function run_test() +{ + // Ensure the profiler is already running when the test starts. + Profiler.StartProfiler(1000000, 1, ["js"], 1); + + DevToolsUtils.waitForTime(WAIT_TIME).then(() => { + + get_chrome_actors((client, form) => { + let actor = form.profilerActor; + test_start_time(client, actor, () => { + client.close().then(do_test_finished); + }); + }); + }); + + do_test_pending(); +} + +function test_start_time(client, actor, callback) { + // Profiler should already be active at this point. + client.request({ to: actor, type: "isActive" }, firstResponse => { + do_check_true(Profiler.IsActive()); + do_check_true(firstResponse.isActive); + do_check_true(firstResponse.currentTime > 0); + + client.request({ to: actor, type: "getProfile" }, secondResponse => { + do_check_true("profile" in secondResponse); + do_check_true(secondResponse.currentTime > firstResponse.currentTime); + + callback(); + }); + }); +} diff --git a/devtools/server/tests/unit/test_profiler_bufferstatus.js b/devtools/server/tests/unit/test_profiler_bufferstatus.js new file mode 100644 index 000000000..9c86bf817 --- /dev/null +++ b/devtools/server/tests/unit/test_profiler_bufferstatus.js @@ -0,0 +1,127 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests if the profiler actor returns its buffer status via getBufferInfo. + */ + +const Profiler = Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler); +const INITIAL_WAIT_TIME = 100; // ms +const MAX_WAIT_TIME = 20000; // ms +const MAX_PROFILER_ENTRIES = 10000000; + +function run_test() +{ + // Ensure the profiler is not running when the test starts (it could + // happen if the MOZ_PROFILER_STARTUP environment variable is set). + Profiler.StopProfiler(); + + get_chrome_actors((client, form) => { + let actor = form.profilerActor; + check_empty_buffer(client, actor, () => { + activate_profiler(client, actor, startTime => { + wait_for_samples(client, actor, () => { + check_buffer(client, actor, () => { + deactivate_profiler(client, actor, () => { + client.close().then(do_test_finished); + }); + }); + }); + }); + }); + }); + + do_test_pending(); +} + +function check_buffer(client, actor, callback) +{ + client.request({ to: actor, type: "isActive" }, response => { + do_check_true(typeof response.position === "number"); + do_check_true(typeof response.totalSize === "number"); + do_check_true(typeof response.generation === "number"); + do_check_true(response.position > 0 && response.position < response.totalSize); + do_check_true(response.totalSize === MAX_PROFILER_ENTRIES); + // There's no way we'll fill the buffer in this test. + do_check_true(response.generation === 0); + + callback(); + }); +} + +function check_empty_buffer(client, actor, callback) +{ + client.request({ to: actor, type: "isActive" }, response => { + do_check_false(Profiler.IsActive()); + do_check_false(response.isActive); + do_check_true(response.position === void 0); + do_check_true(response.totalSize === void 0); + do_check_true(response.generation === void 0); + do_check_false(response.isActive); + do_check_eq(response.currentTime, undefined); + calback(); + }); +} + +function activate_profiler(client, actor, callback) +{ + client.request({ to: actor, type: "startProfiler", entries: MAX_PROFILER_ENTRIES }, response => { + do_check_true(response.started); + client.request({ to: actor, type: "isActive" }, response => { + do_check_true(response.isActive); + callback(response.currentTime); + }); + }); +} + +function deactivate_profiler(client, actor, callback) +{ + client.request({ to: actor, type: "stopProfiler" }, response => { + do_check_false(response.started); + client.request({ to: actor, type: "isActive" }, response => { + do_check_false(response.isActive); + callback(); + }); + }); +} + +function wait_for_samples(client, actor, callback) +{ + function attempt(delay) + { + // No idea why, but Components.stack.sourceLine returns null. + let funcLine = Components.stack.lineNumber - 3; + + // Spin for the requested time, then take a sample. + let start = Date.now(); + let stack; + do_print("Attempt: delay = " + delay); + while (Date.now() - start < delay) { stack = Components.stack; } + do_print("Attempt: finished waiting."); + + client.request({ to: actor, type: "getProfile" }, response => { + // At this point, we may or may not have samples, depending on + // whether the spin loop above has given the profiler enough time + // to get started. + if (response.profile.threads[0].samples.length == 0) { + if (delay < MAX_WAIT_TIME) { + // Double the spin-wait time and try again. + do_print("Attempt: no samples, going around again."); + return attempt(delay * 2); + } else { + // We've waited long enough, so just fail. + do_print("Attempt: waited a long time, but no samples were collected."); + do_print("Giving up."); + do_check_true(false); + return; + } + } + callback(); + }); + } + + // Start off with a 100 millisecond delay. + attempt(INITIAL_WAIT_TIME); +} diff --git a/devtools/server/tests/unit/test_profiler_close.js b/devtools/server/tests/unit/test_profiler_close.js new file mode 100644 index 000000000..a8b3040fd --- /dev/null +++ b/devtools/server/tests/unit/test_profiler_close.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests whether the profiler module is kept active when there are multiple + * client consumers and one requests deactivation. + */ + +const Profiler = Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler); + +function run_test() +{ + get_chrome_actors((client1, form1) => { + let actor1 = form1.profilerActor; + get_chrome_actors((client2, form2) => { + let actor2 = form2.profilerActor; + test_close(client1, actor1, client2, actor2, () => { + client1.close(() => { + client2.close(() => { + do_test_finished(); + }); + }); + }); + }); + }); + + do_test_pending(); +} + +function activate_profiler(client, actor, callback) +{ + client.request({ to: actor, type: "startProfiler" }, response => { + do_check_true(response.started); + do_check_true(Profiler.IsActive()); + + client.request({ to: actor, type: "isActive" }, response => { + do_check_true(response.isActive); + callback(); + }); + }); +} + +function deactivate_profiler(client, actor, callback) +{ + client.request({ to: actor, type: "stopProfiler" }, response => { + do_check_false(response.started); + do_check_true(Profiler.IsActive()); + + client.request({ to: actor, type: "isActive" }, response => { + do_check_true(response.isActive); + callback(); + }); + }); +} + +function test_close(client1, actor1, client2, actor2, callback) +{ + activate_profiler(client1, actor1, () => { + activate_profiler(client2, actor2, () => { + deactivate_profiler(client1, actor1, () => { + deactivate_profiler(client2, actor2, () => { + callback(); + }); + }); + }); + }); +} diff --git a/devtools/server/tests/unit/test_profiler_data.js b/devtools/server/tests/unit/test_profiler_data.js new file mode 100644 index 000000000..2a79eed1f --- /dev/null +++ b/devtools/server/tests/unit/test_profiler_data.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests if the profiler actor can correctly retrieve a profile after + * it is activated. + */ + +const Profiler = Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler); +const INITIAL_WAIT_TIME = 100; // ms +const MAX_WAIT_TIME = 20000; // ms + +function run_test() +{ + get_chrome_actors((client, form) => { + let actor = form.profilerActor; + activate_profiler(client, actor, startTime => { + test_data(client, actor, startTime, () => { + deactivate_profiler(client, actor, () => { + client.close().then(do_test_finished); + }); + }); + }); + }); + + do_test_pending(); +} + +function activate_profiler(client, actor, callback) +{ + client.request({ to: actor, type: "startProfiler" }, response => { + do_check_true(response.started); + client.request({ to: actor, type: "isActive" }, response => { + do_check_true(response.isActive); + callback(response.currentTime); + }); + }); +} + +function deactivate_profiler(client, actor, callback) +{ + client.request({ to: actor, type: "stopProfiler" }, response => { + do_check_false(response.started); + client.request({ to: actor, type: "isActive" }, response => { + do_check_false(response.isActive); + callback(); + }); + }); +} + +function test_data(client, actor, startTime, callback) +{ + function attempt(delay) + { + // No idea why, but Components.stack.sourceLine returns null. + let funcLine = Components.stack.lineNumber - 3; + + // Spin for the requested time, then take a sample. + let start = Date.now(); + let stack; + do_print("Attempt: delay = " + delay); + while (Date.now() - start < delay) { stack = Components.stack; } + do_print("Attempt: finished waiting."); + + client.request({ to: actor, type: "getProfile", startTime }, response => { + // Any valid getProfile response should have the following top + // level structure. + do_check_eq(typeof response.profile, "object"); + do_check_eq(typeof response.profile.meta, "object"); + do_check_eq(typeof response.profile.meta.platform, "string"); + do_check_eq(typeof response.profile.threads, "object"); + do_check_eq(typeof response.profile.threads[0], "object"); + do_check_eq(typeof response.profile.threads[0].samples, "object"); + + // At this point, we may or may not have samples, depending on + // whether the spin loop above has given the profiler enough time + // to get started. + if (response.profile.threads[0].samples.length == 0) { + if (delay < MAX_WAIT_TIME) { + // Double the spin-wait time and try again. + do_print("Attempt: no samples, going around again."); + return attempt(delay * 2); + } else { + // We've waited long enough, so just fail. + do_print("Attempt: waited a long time, but no samples were collected."); + do_print("Giving up."); + do_check_true(false); + return; + } + } + + // Now check the samples. At least one sample is expected to + // have been in the busy wait above. + let loc = stack.name + " (" + stack.filename + ":" + funcLine + ")"; + let thread0 = response.profile.threads[0]; + do_check_true(thread0.samples.data.some(sample => { + let frames = getInflatedStackLocations(thread0, sample); + return frames.length != 0 && + frames.some(location => (location == loc)); + })); + + callback(); + }); + } + + // Start off with a 100 millisecond delay. + attempt(INITIAL_WAIT_TIME); +} diff --git a/devtools/server/tests/unit/test_profiler_events-01.js b/devtools/server/tests/unit/test_profiler_events-01.js new file mode 100644 index 000000000..b8ca592b9 --- /dev/null +++ b/devtools/server/tests/unit/test_profiler_events-01.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the event notification service for the profiler actor. + */ + +const Profiler = Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler); +const { ProfilerFront } = require("devtools/shared/fronts/profiler"); + +function run_test() { + run_next_test(); +} + +add_task(function* () { + let [client, form] = yield getChromeActors(); + let front = new ProfilerFront(client, form); + + let events = [0, 0, 0, 0]; + front.on("console-api-profiler", () => events[0]++); + front.on("profiler-started", () => events[1]++); + front.on("profiler-stopped", () => events[2]++); + client.addListener("eventNotification", (type, response) => { + do_check_true(type === "eventNotification"); + events[3]++; + }); + + yield front.startProfiler(); + yield front.stopProfiler(); + + // All should be empty without binding events + do_check_true(events[0] === 0); + do_check_true(events[1] === 0); + do_check_true(events[2] === 0); + do_check_true(events[3] === 0); + + let ret = yield front.registerEventNotifications({ events: ["console-api-profiler", "profiler-started", "profiler-stopped"] }); + do_check_true(ret.registered.length === 3); + + yield front.startProfiler(); + do_check_true(events[0] === 0); + do_check_true(events[1] === 1); + do_check_true(events[2] === 0); + do_check_true(events[3] === 1, "compatibility events supported for eventNotifications"); + + yield front.stopProfiler(); + do_check_true(events[0] === 0); + do_check_true(events[1] === 1); + do_check_true(events[2] === 1); + do_check_true(events[3] === 2, "compatibility events supported for eventNotifications"); + + ret = yield front.unregisterEventNotifications({ events: ["console-api-profiler", "profiler-started", "profiler-stopped"] }); + do_check_true(ret.registered.length === 3); +}); + +function getChromeActors() { + let deferred = promise.defer(); + get_chrome_actors((client, form) => deferred.resolve([client, form])); + return deferred.promise; +} diff --git a/devtools/server/tests/unit/test_profiler_events-02.js b/devtools/server/tests/unit/test_profiler_events-02.js new file mode 100644 index 000000000..fed702043 --- /dev/null +++ b/devtools/server/tests/unit/test_profiler_events-02.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the event notification service for the profiler actor. + */ + +const Profiler = Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler); +const MAX_PROFILER_ENTRIES = 10000000; +const { ProfilerFront } = require("devtools/shared/fronts/profiler"); +const { waitForTime } = DevToolsUtils; + +function run_test() { + run_next_test(); +} + +add_task(function* () { + let [client, form] = yield getChromeActors(); + let front = new ProfilerFront(client, form); + + // Ensure the profiler is not running when the test starts (it could + // happen if the MOZ_PROFILER_STARTUP environment variable is set). + Profiler.StopProfiler(); + let eventsCalled = 0; + let handledThreeTimes = promise.defer(); + + front.on("profiler-status", (response) => { + dump("'profiler-status' fired\n"); + do_check_true(typeof response.position === "number"); + do_check_true(typeof response.totalSize === "number"); + do_check_true(typeof response.generation === "number"); + do_check_true(response.position > 0 && response.position < response.totalSize); + do_check_true(response.totalSize === MAX_PROFILER_ENTRIES); + // There's no way we'll fill the buffer in this test. + do_check_true(response.generation === 0); + + eventsCalled++; + if (eventsCalled > 2) { + handledThreeTimes.resolve(); + } + }); + + yield front.setProfilerStatusInterval(1); + dump("Set the profiler-status event interval to 1\n"); + yield front.startProfiler(); + yield waitForTime(500); + yield front.stopProfiler(); + + do_check_true(eventsCalled === 0, "No 'profiler-status' events should be fired before registering."); + + let ret = yield front.registerEventNotifications({ events: ["profiler-status"] }); + do_check_true(ret.registered.length === 1); + + yield front.startProfiler(); + yield handledThreeTimes.promise; + yield front.stopProfiler(); + do_check_true(eventsCalled >= 3, "profiler-status fired atleast three times while recording"); + + let totalEvents = eventsCalled; + yield waitForTime(50); + do_check_true(totalEvents === eventsCalled, "No more profiler-status events after recording."); +}); + +function getChromeActors() { + let deferred = promise.defer(); + get_chrome_actors((client, form) => deferred.resolve([client, form])); + return deferred.promise; +} diff --git a/devtools/server/tests/unit/test_profiler_getbufferinfo.js b/devtools/server/tests/unit/test_profiler_getbufferinfo.js new file mode 100644 index 000000000..1ec536738 --- /dev/null +++ b/devtools/server/tests/unit/test_profiler_getbufferinfo.js @@ -0,0 +1,123 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests if the profiler actor returns its buffer status via getBufferInfo. + */ + +const Profiler = Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler); +const INITIAL_WAIT_TIME = 100; // ms +const MAX_WAIT_TIME = 20000; // ms +const MAX_PROFILER_ENTRIES = 10000000; + +function run_test() +{ + // Ensure the profiler is not running when the test starts (it could + // happen if the MOZ_PROFILER_STARTUP environment variable is set). + Profiler.StopProfiler(); + + get_chrome_actors((client, form) => { + let actor = form.profilerActor; + check_empty_buffer(client, actor, () => { + activate_profiler(client, actor, startTime => { + wait_for_samples(client, actor, () => { + check_buffer(client, actor, () => { + deactivate_profiler(client, actor, () => { + client.close().then(do_test_finished); + }); + }); + }); + }); + }); + }); + + do_test_pending(); +} + +function check_empty_buffer(client, actor, callback) +{ + client.request({ to: actor, type: "getBufferInfo" }, response => { + do_check_true(response.position === 0); + do_check_true(response.totalSize === 0); + do_check_true(response.generation === 0); + callback(); + }); +} + +function check_buffer(client, actor, callback) +{ + client.request({ to: actor, type: "getBufferInfo" }, response => { + do_check_true(typeof response.position === "number"); + do_check_true(typeof response.totalSize === "number"); + do_check_true(typeof response.generation === "number"); + do_check_true(response.position > 0 && response.position < response.totalSize); + do_check_true(response.totalSize === MAX_PROFILER_ENTRIES); + // There's no way we'll fill the buffer in this test. + do_check_true(response.generation === 0); + + callback(); + }); +} + +function activate_profiler(client, actor, callback) +{ + client.request({ to: actor, type: "startProfiler", entries: MAX_PROFILER_ENTRIES }, response => { + do_check_true(response.started); + client.request({ to: actor, type: "isActive" }, response => { + do_check_true(response.isActive); + callback(response.currentTime); + }); + }); +} + +function deactivate_profiler(client, actor, callback) +{ + client.request({ to: actor, type: "stopProfiler" }, response => { + do_check_false(response.started); + client.request({ to: actor, type: "isActive" }, response => { + do_check_false(response.isActive); + callback(); + }); + }); +} + +function wait_for_samples(client, actor, callback) +{ + function attempt(delay) + { + // No idea why, but Components.stack.sourceLine returns null. + let funcLine = Components.stack.lineNumber - 3; + + // Spin for the requested time, then take a sample. + let start = Date.now(); + let stack; + do_print("Attempt: delay = " + delay); + while (Date.now() - start < delay) { stack = Components.stack; } + do_print("Attempt: finished waiting."); + + client.request({ to: actor, type: "getProfile" }, response => { + // At this point, we may or may not have samples, depending on + // whether the spin loop above has given the profiler enough time + // to get started. + if (response.profile.threads[0].samples.length == 0) { + if (delay < MAX_WAIT_TIME) { + // Double the spin-wait time and try again. + do_print("Attempt: no samples, going around again."); + return attempt(delay * 2); + } else { + // We've waited long enough, so just fail. + do_print("Attempt: waited a long time, but no samples were collected."); + do_print("Giving up."); + do_check_true(false); + return; + } + } + callback(); + }); + } + + // Start off with a 100 millisecond delay. + attempt(INITIAL_WAIT_TIME); +} diff --git a/devtools/server/tests/unit/test_profiler_getfeatures.js b/devtools/server/tests/unit/test_profiler_getfeatures.js new file mode 100644 index 000000000..5b37e7d55 --- /dev/null +++ b/devtools/server/tests/unit/test_profiler_getfeatures.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests whether the profiler responds to "getFeatures" adequately. + */ + +const Profiler = Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler); + +function run_test() +{ + get_chrome_actors((client, form) => { + let actor = form.profilerActor; + test_getfeatures(client, actor, () => { + client.close().then(() => { + do_test_finished(); + }); + }); + }); + + do_test_pending(); +} + +function test_getfeatures(client, actor, callback) +{ + client.request({ to: actor, type: "getFeatures" }, response => { + do_check_eq(typeof response.features, "object"); + do_check_true(response.features.length >= 1); + do_check_eq(typeof response.features[0], "string"); + do_check_true(response.features.indexOf("js") != -1); + callback(); + }); +} diff --git a/devtools/server/tests/unit/test_profiler_getsharedlibraryinformation.js b/devtools/server/tests/unit/test_profiler_getsharedlibraryinformation.js new file mode 100644 index 000000000..a36577320 --- /dev/null +++ b/devtools/server/tests/unit/test_profiler_getsharedlibraryinformation.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests whether the profiler responds to "getSharedLibraryInformation" adequately. + */ + +const Profiler = Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler); + +function run_test() +{ + get_chrome_actors((client, form) => { + let actor = form.profilerActor; + test_getsharedlibraryinformation(client, actor, () => { + client.close().then(() => { + do_test_finished(); + }); + }); + }); + + do_test_pending(); +} + +function test_getsharedlibraryinformation(client, actor, callback) +{ + client.request({ to: actor, type: "getSharedLibraryInformation" }, response => { + do_check_eq(typeof response.sharedLibraryInformation, "string"); + let libs = []; + try { + libs = JSON.parse(response.sharedLibraryInformation); + } catch (e) { + do_check_true(false); + } + do_check_eq(typeof libs, "object"); + do_check_true(libs.length >= 1); + do_check_eq(typeof libs[0], "object"); + do_check_eq(typeof libs[0].name, "string"); + do_check_eq(typeof libs[0].start, "number"); + do_check_eq(typeof libs[0].end, "number"); + do_check_true(libs[0].start <= libs[0].end); + callback(); + }); +} diff --git a/devtools/server/tests/unit/test_promise_state-01.js b/devtools/server/tests/unit/test_promise_state-01.js new file mode 100644 index 000000000..a525560ab --- /dev/null +++ b/devtools/server/tests/unit/test_promise_state-01.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that the preview in a Promise's grip is correct when the Promise is + * pending. + */ + +function run_test() +{ + initTestDebuggerServer(); + const debuggee = addTestGlobal("test-promise-state"); + const client = new DebuggerClient(DebuggerServer.connectPipe()); + client.connect().then(function () { + attachTestTabAndResume(client, "test-promise-state", function (response, tabClient, threadClient) { + Task.spawn(function* () { + const packet = yield executeOnNextTickAndWaitForPause(() => evalCode(debuggee), client); + + const grip = packet.frame.environment.bindings.variables.p; + ok(grip.value.preview); + equal(grip.value.class, "Promise"); + equal(grip.value.promiseState.state, "pending"); + + finishClient(client); + }); + }); + }); + do_test_pending(); +} + +function evalCode(debuggee) { + Components.utils.evalInSandbox( + "doTest();\n" + + function doTest() { + var p = new Promise(function () {}); + debugger; + }, + debuggee + ); +} diff --git a/devtools/server/tests/unit/test_promise_state-02.js b/devtools/server/tests/unit/test_promise_state-02.js new file mode 100644 index 000000000..cf44f1946 --- /dev/null +++ b/devtools/server/tests/unit/test_promise_state-02.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that the preview in a Promise's grip is correct when the Promise is + * fulfilled. + */ + +function run_test() +{ + initTestDebuggerServer(); + const debuggee = addTestGlobal("test-promise-state"); + const client = new DebuggerClient(DebuggerServer.connectPipe()); + client.connect().then(function () { + attachTestTabAndResume(client, "test-promise-state", function (response, tabClient, threadClient) { + Task.spawn(function* () { + const packet = yield executeOnNextTickAndWaitForPause(() => evalCode(debuggee), client); + + const grip = packet.frame.environment.bindings.variables.p; + ok(grip.value.preview); + equal(grip.value.class, "Promise"); + equal(grip.value.promiseState.state, "fulfilled"); + equal(grip.value.promiseState.value.actorID, packet.frame.arguments[0].actorID, + "The promise's fulfilled state value should be the same value passed to the then function"); + + finishClient(client); + }); + }); + }); + do_test_pending(); +} + +function evalCode(debuggee) { + Components.utils.evalInSandbox( + "doTest();\n" + + function doTest() { + var resolved = Promise.resolve({}); + resolved.then(() => { + var p = resolved; + debugger; + }); + }, + debuggee + ); +} diff --git a/devtools/server/tests/unit/test_promise_state-03.js b/devtools/server/tests/unit/test_promise_state-03.js new file mode 100644 index 000000000..cf64e3e27 --- /dev/null +++ b/devtools/server/tests/unit/test_promise_state-03.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that the preview in a Promise's grip is correct when the Promise is + * rejected. + */ + +function run_test() +{ + initTestDebuggerServer(); + const debuggee = addTestGlobal("test-promise-state"); + const client = new DebuggerClient(DebuggerServer.connectPipe()); + client.connect().then(function () { + attachTestTabAndResume(client, "test-promise-state", function (response, tabClient, threadClient) { + Task.spawn(function* () { + const packet = yield executeOnNextTickAndWaitForPause(() => evalCode(debuggee), client); + + const grip = packet.frame.environment.bindings.variables.p; + ok(grip.value.preview); + equal(grip.value.class, "Promise"); + equal(grip.value.promiseState.state, "rejected"); + equal(grip.value.promiseState.reason.actorID, packet.frame.arguments[0].actorID, + "The promise's rejected state reason should be the same value passed to the then function"); + + finishClient(client); + }); + }); + }); + do_test_pending(); +} + +function evalCode(debuggee) { + Components.utils.evalInSandbox( + "doTest();\n" + + function doTest() { + var resolved = Promise.reject(new Error("uh oh")); + resolved.then(null, () => { + var p = resolved; + debugger; + }); + }, + debuggee + ); +} diff --git a/devtools/server/tests/unit/test_promises_actor_attach.js b/devtools/server/tests/unit/test_promises_actor_attach.js new file mode 100644 index 000000000..17c2a1f41 --- /dev/null +++ b/devtools/server/tests/unit/test_promises_actor_attach.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we can attach and detach to the PromisesActor under the correct + * states. + */ + +const { PromisesFront } = require("devtools/shared/fronts/promises"); + +add_task(function* () { + let client = yield startTestDebuggerServer("promises-actor-test"); + let chromeActors = yield getChromeActors(client); + + // We have to attach the chrome TabActor before playing with the PromiseActor + yield attachTab(client, chromeActors); + yield testAttach(client, chromeActors); + + let response = yield listTabs(client); + let targetTab = findTab(response.tabs, "promises-actor-test"); + ok(targetTab, "Found our target tab."); + + let [ tabResponse ] = yield attachTab(client, targetTab); + + yield testAttach(client, tabResponse); + + yield close(client); +}); + +function* testAttach(client, parent) { + let promises = PromisesFront(client, parent); + + try { + yield promises.detach(); + ok(false, "Should not be able to detach when in a detached state."); + } catch (e) { + ok(true, "Expected detach to fail when already in a detached state."); + } + + yield promises.attach(); + ok(true, "Expected attach to succeed."); + + try { + yield promises.attach(); + ok(false, "Should not be able to attach when in an attached state."); + } catch (e) { + ok(true, "Expected attach to fail when already in an attached state."); + } + + yield promises.detach(); + ok(true, "Expected detach to succeed."); +} diff --git a/devtools/server/tests/unit/test_promises_actor_exist.js b/devtools/server/tests/unit/test_promises_actor_exist.js new file mode 100644 index 000000000..13eef3e99 --- /dev/null +++ b/devtools/server/tests/unit/test_promises_actor_exist.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that the PromisesActor exists in the TabActors and ChromeActors. + */ + +add_task(function* () { + let client = yield startTestDebuggerServer("promises-actor-test"); + + let response = yield listTabs(client); + let targetTab = findTab(response.tabs, "promises-actor-test"); + ok(targetTab, "Found our target tab."); + + // Attach to the TabActor and check the response + client.request({ to: targetTab.actor, type: "attach" }, response => { + ok(!("error" in response), "Expect no error in response."); + ok(response.from, targetTab.actor, + "Expect the target TabActor in response form field."); + ok(response.type, "tabAttached", + "Expect tabAttached in the response type."); + is(typeof response.promisesActor === "string", + "Should have a tab context PromisesActor."); + }); + + let chromeActors = yield getChromeActors(client); + ok(typeof chromeActors.promisesActor === "string", + "Should have a chrome context PromisesActor."); +}); diff --git a/devtools/server/tests/unit/test_promises_actor_list_promises.js b/devtools/server/tests/unit/test_promises_actor_list_promises.js new file mode 100644 index 000000000..f5b273121 --- /dev/null +++ b/devtools/server/tests/unit/test_promises_actor_list_promises.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we can get the list of all live Promise objects from the + * PromisesActor. + */ + +"use strict"; + +const { PromisesFront } = require("devtools/shared/fronts/promises"); +const SECRET = "MyLittleSecret"; + +add_task(function* () { + let client = yield startTestDebuggerServer("promises-actor-test"); + let chromeActors = yield getChromeActors(client); + + // We have to attach the chrome TabActor before playing with the PromiseActor + yield attachTab(client, chromeActors); + yield testListPromises(client, chromeActors, v => + new Promise(resolve => resolve(v))); + + let response = yield listTabs(client); + let targetTab = findTab(response.tabs, "promises-actor-test"); + ok(targetTab, "Found our target tab."); + + yield testListPromises(client, targetTab, v => { + const debuggee = DebuggerServer.getTestGlobal("promises-actor-test"); + return debuggee.Promise.resolve(v); + }); + + yield close(client); +}); + +function* testListPromises(client, form, makePromise) { + let resolution = SECRET + Math.random(); + let promise = makePromise(resolution); + let front = PromisesFront(client, form); + + yield front.attach(); + + let promises = yield front.listPromises(); + + let found = false; + for (let p of promises) { + equal(p.type, "object", "Expect type to be Object"); + equal(p.class, "Promise", "Expect class to be Promise"); + equal(typeof p.promiseState.creationTimestamp, "number", + "Expect creation timestamp to be a number"); + equal(typeof p.promiseState.timeToSettle, "number", + "Expect time to settle to be a number"); + + if (p.promiseState.state === "fulfilled" && + p.promiseState.value === resolution) { + found = true; + } + } + + ok(found, "Found our promise"); + yield front.detach(); + // Appease eslint + void promise; +} diff --git a/devtools/server/tests/unit/test_promises_actor_onnewpromise.js b/devtools/server/tests/unit/test_promises_actor_onnewpromise.js new file mode 100644 index 000000000..04b3e6510 --- /dev/null +++ b/devtools/server/tests/unit/test_promises_actor_onnewpromise.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we can get the list of all new Promise objects from the + * PromisesActor onNewPromise event handler. + */ + +"use strict"; + +const { PromisesFront } = require("devtools/shared/fronts/promises"); + +var events = require("sdk/event/core"); + +add_task(function* () { + let client = yield startTestDebuggerServer("promises-actor-test"); + let chromeActors = yield getChromeActors(client); + + ok(Promise.toString().includes("native code"), "Expect native DOM Promise"); + + // We have to attach the chrome TabActor before playing with the PromiseActor + yield attachTab(client, chromeActors); + yield testNewPromisesEvent(client, chromeActors, + v => new Promise(resolve => resolve(v))); + + let response = yield listTabs(client); + let targetTab = findTab(response.tabs, "promises-actor-test"); + ok(targetTab, "Found our target tab."); + + yield testNewPromisesEvent(client, targetTab, v => { + const debuggee = DebuggerServer.getTestGlobal("promises-actor-test"); + return debuggee.Promise.resolve(v); + }); + + yield close(client); +}); + +function* testNewPromisesEvent(client, form, makePromise) { + let front = PromisesFront(client, form); + let resolution = "MyLittleSecret" + Math.random(); + let found = false; + + yield front.attach(); + yield front.listPromises(); + + let onNewPromise = new Promise(resolve => { + events.on(front, "new-promises", promises => { + for (let p of promises) { + equal(p.type, "object", "Expect type to be Object"); + equal(p.class, "Promise", "Expect class to be Promise"); + equal(typeof p.promiseState.creationTimestamp, "number", + "Expect creation timestamp to be a number"); + + if (p.promiseState.state === "fulfilled" && + p.promiseState.value === resolution) { + found = true; + resolve(); + } else { + dump("Found non-target promise\n"); + } + } + }); + }); + + let promise = makePromise(resolution); + + yield onNewPromise; + ok(found, "Found our new promise"); + yield front.detach(); + // Appease eslint + void promise; +} diff --git a/devtools/server/tests/unit/test_promises_actor_onpromisesettled.js b/devtools/server/tests/unit/test_promises_actor_onpromisesettled.js new file mode 100644 index 000000000..ab4774733 --- /dev/null +++ b/devtools/server/tests/unit/test_promises_actor_onpromisesettled.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we can get the list of Promise objects that have settled from the + * PromisesActor onPromiseSettled event handler. + */ + +"use strict"; + +Cu.import("resource://testing-common/PromiseTestUtils.jsm", this); + +const { PromisesFront } = require("devtools/shared/fronts/promises"); + +var events = require("sdk/event/core"); + +add_task(function* () { + let client = yield startTestDebuggerServer("promises-actor-test"); + let chromeActors = yield getChromeActors(client); + + ok(Promise.toString().includes("native code"), "Expect native DOM Promise"); + + // We have to attach the chrome TabActor before playing with the PromiseActor + yield attachTab(client, chromeActors); + yield testPromisesSettled(client, chromeActors, + v => new Promise(resolve => resolve(v)), + v => new Promise((resolve, reject) => reject(v))); + + let response = yield listTabs(client); + let targetTab = findTab(response.tabs, "promises-actor-test"); + ok(targetTab, "Found our target tab."); + + yield testPromisesSettled(client, targetTab, v => { + const debuggee = DebuggerServer.getTestGlobal("promises-actor-test"); + return debuggee.Promise.resolve(v); + }, v => { + const debuggee = DebuggerServer.getTestGlobal("promises-actor-test"); + return debuggee.Promise.reject(v); + }); + + yield close(client); +}); + +function* testPromisesSettled(client, form, makeResolvePromise, + makeRejectPromise) { + let front = PromisesFront(client, form); + let resolution = "MyLittleSecret" + Math.random(); + + yield front.attach(); + yield front.listPromises(); + + let onPromiseSettled = oncePromiseSettled(front, resolution, true, false); + let resolvedPromise = makeResolvePromise(resolution); + let foundResolvedPromise = yield onPromiseSettled; + ok(foundResolvedPromise, "Found our resolved promise"); + + PromiseTestUtils.expectUncaughtRejection(r => r.message == resolution); + onPromiseSettled = oncePromiseSettled(front, resolution, false, true); + let rejectedPromise = makeRejectPromise(resolution); + let foundRejectedPromise = yield onPromiseSettled; + ok(foundRejectedPromise, "Found our rejected promise"); + + yield front.detach(); + // Appease eslint + void resolvedPromise; + void rejectedPromise; +} + +function oncePromiseSettled(front, resolution, resolveValue, rejectValue) { + return new Promise(resolve => { + events.on(front, "promises-settled", promises => { + for (let p of promises) { + equal(p.type, "object", "Expect type to be Object"); + equal(p.class, "Promise", "Expect class to be Promise"); + equal(typeof p.promiseState.creationTimestamp, "number", + "Expect creation timestamp to be a number"); + equal(typeof p.promiseState.timeToSettle, "number", + "Expect time to settle to be a number"); + + if (p.promiseState.state === "fulfilled" && + p.promiseState.value === resolution) { + resolve(resolveValue); + } else if (p.promiseState.state === "rejected" && + p.promiseState.reason === resolution) { + resolve(rejectValue); + } else { + dump("Found non-target promise\n"); + } + } + }); + }); +} diff --git a/devtools/server/tests/unit/test_promises_client_getdependentpromises.js b/devtools/server/tests/unit/test_promises_client_getdependentpromises.js new file mode 100644 index 000000000..8900cf81c --- /dev/null +++ b/devtools/server/tests/unit/test_promises_client_getdependentpromises.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we can get the list of dependent promises from the ObjectClient. + */ + +"use strict"; + +const { PromisesFront } = require("devtools/shared/fronts/promises"); + +var events = require("sdk/event/core"); + +add_task(function* () { + let client = yield startTestDebuggerServer("test-promises-dependentpromises"); + let chromeActors = yield getChromeActors(client); + yield attachTab(client, chromeActors); + + ok(Promise.toString().includes("native code"), "Expect native DOM Promise."); + + yield testGetDependentPromises(client, chromeActors, () => { + let p = new Promise(() => {}); + p.name = "p"; + let q = p.then(); + q.name = "q"; + let r = p.then(null, () => {}); + r.name = "r"; + + return p; + }); + + let response = yield listTabs(client); + let targetTab = findTab(response.tabs, "test-promises-dependentpromises"); + ok(targetTab, "Found our target tab."); + yield attachTab(client, targetTab); + + yield testGetDependentPromises(client, targetTab, () => { + const debuggee = + DebuggerServer.getTestGlobal("test-promises-dependentpromises"); + + let p = new debuggee.Promise(() => {}); + p.name = "p"; + let q = p.then(); + q.name = "q"; + let r = p.then(null, () => {}); + r.name = "r"; + + return p; + }); + + yield close(client); +}); + +function* testGetDependentPromises(client, form, makePromises) { + let front = PromisesFront(client, form); + + yield front.attach(); + yield front.listPromises(); + + // Get the grip for promise p + let onNewPromise = new Promise(resolve => { + events.on(front, "new-promises", promises => { + for (let p of promises) { + if (p.preview.ownProperties.name && + p.preview.ownProperties.name.value === "p") { + resolve(p); + } + } + }); + }); + + let promise = makePromises(); + + let grip = yield onNewPromise; + ok(grip, "Found our promise p."); + + let objectClient = new ObjectClient(client, grip); + ok(objectClient, "Got Object Client."); + + // Get the dependent promises for promise p and assert that the list of + // dependent promises is correct + yield new Promise(resolve => { + objectClient.getDependentPromises(response => { + let dependentNames = response.promises.map(p => + p.preview.ownProperties.name.value); + let expectedDependentNames = ["q", "r"]; + + equal(dependentNames.length, expectedDependentNames.length, + "Got expected number of dependent promises."); + + for (let i = 0; i < dependentNames.length; i++) { + equal(dependentNames[i], expectedDependentNames[i], + "Got expected dependent name."); + } + + for (let p of response.promises) { + equal(p.type, "object", "Expect type to be Object."); + equal(p.class, "Promise", "Expect class to be Promise."); + equal(typeof p.promiseState.creationTimestamp, "number", + "Expect creation timestamp to be a number."); + ok(!p.promiseState.timeToSettle, + "Expect time to settle to be undefined."); + } + + resolve(); + }); + }); + + yield front.detach(); + // Appease eslint + void promise; +} diff --git a/devtools/server/tests/unit/test_promises_object_creationtimestamp.js b/devtools/server/tests/unit/test_promises_object_creationtimestamp.js new file mode 100644 index 000000000..1360be56a --- /dev/null +++ b/devtools/server/tests/unit/test_promises_object_creationtimestamp.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we get the approximate time range for promise creation timestamp. + */ + +"use strict"; + +const { PromisesFront } = require("devtools/shared/fronts/promises"); + +var events = require("sdk/event/core"); + +add_task(function* () { + let client = yield startTestDebuggerServer("promises-object-test"); + let chromeActors = yield getChromeActors(client); + + ok(Promise.toString().includes("native code"), "Expect native DOM Promise."); + + // We have to attach the chrome TabActor before playing with the PromiseActor + yield attachTab(client, chromeActors); + yield testPromiseCreationTimestamp(client, chromeActors, v => { + return new Promise(resolve => resolve(v)); + }); + + let response = yield listTabs(client); + let targetTab = findTab(response.tabs, "promises-object-test"); + ok(targetTab, "Found our target tab."); + + yield testPromiseCreationTimestamp(client, targetTab, v => { + const debuggee = DebuggerServer.getTestGlobal("promises-object-test"); + return debuggee.Promise.resolve(v); + }); + + yield close(client); +}); + +function* testPromiseCreationTimestamp(client, form, makePromise) { + let front = PromisesFront(client, form); + let resolution = "MyLittleSecret" + Math.random(); + + yield front.attach(); + yield front.listPromises(); + + let onNewPromise = new Promise(resolve => { + events.on(front, "new-promises", promises => { + for (let p of promises) { + if (p.promiseState.state === "fulfilled" && + p.promiseState.value === resolution) { + resolve(p); + } + } + }); + }); + + let start = Date.now(); + let promise = makePromise(resolution); + let end = Date.now(); + + let grip = yield onNewPromise; + ok(grip, "Found our new promise."); + + let creationTimestamp = grip.promiseState.creationTimestamp; + + ok(start - 1 <= creationTimestamp && creationTimestamp <= end + 1, + "Expect promise creation timestamp to be within elapsed time range."); + + yield front.detach(); + // Appease eslint + void promise; +} diff --git a/devtools/server/tests/unit/test_promises_object_timetosettle-01.js b/devtools/server/tests/unit/test_promises_object_timetosettle-01.js new file mode 100644 index 000000000..1b3240e3d --- /dev/null +++ b/devtools/server/tests/unit/test_promises_object_timetosettle-01.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test whether or not we get the time to settle depending on the state of the + * promise. + */ + +"use strict"; + +const { PromisesFront } = require("devtools/shared/fronts/promises"); + +var events = require("sdk/event/core"); + +add_task(function* () { + let client = yield startTestDebuggerServer("test-promises-timetosettle"); + let chromeActors = yield getChromeActors(client); + + ok(Promise.toString().includes("native code"), "Expect native DOM Promise."); + + // We have to attach the chrome TabActor before playing with the PromiseActor + yield attachTab(client, chromeActors); + yield testGetTimeToSettle(client, chromeActors, () => { + let p = new Promise(() => {}); + p.name = "p"; + let q = p.then(); + q.name = "q"; + + return p; + }); + + let response = yield listTabs(client); + let targetTab = findTab(response.tabs, "test-promises-timetosettle"); + ok(targetTab, "Found our target tab."); + + yield testGetTimeToSettle(client, targetTab, () => { + const debuggee = + DebuggerServer.getTestGlobal("test-promises-timetosettle"); + + let p = new debuggee.Promise(() => {}); + p.name = "p"; + let q = p.then(); + q.name = "q"; + + return p; + }); + + yield close(client); +}); + +function* testGetTimeToSettle(client, form, makePromises) { + let front = PromisesFront(client, form); + + yield front.attach(); + yield front.listPromises(); + + let onNewPromise = new Promise(resolve => { + events.on(front, "new-promises", promises => { + for (let p of promises) { + if (p.promiseState.state === "pending") { + ok(!p.promiseState.timeToSettle, + "Expect no time to settle for unsettled promise."); + } else { + ok(p.promiseState.timeToSettle, + "Expect time to settle for settled promise."); + equal(typeof p.promiseState.timeToSettle, "number", + "Expect time to settle to be a number."); + } + } + resolve(); + }); + }); + + let promise = makePromises(); + + yield onNewPromise; + yield front.detach(); + // Appease eslint + void promise; +} diff --git a/devtools/server/tests/unit/test_promises_object_timetosettle-02.js b/devtools/server/tests/unit/test_promises_object_timetosettle-02.js new file mode 100644 index 000000000..10224d0b9 --- /dev/null +++ b/devtools/server/tests/unit/test_promises_object_timetosettle-02.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we get the expected settlement time for promise time to settle. + */ + +"use strict"; + +const { PromisesFront } = require("devtools/shared/fronts/promises"); +const { setTimeout } = require("sdk/timers"); + +var events = require("sdk/event/core"); + +add_task(function* () { + let client = yield startTestDebuggerServer("test-promises-timetosettle"); + let chromeActors = yield getChromeActors(client); + yield attachTab(client, chromeActors); + + ok(Promise.toString().includes("native code"), "Expect native DOM Promise."); + + // We have to attach the chrome TabActor before playing with the PromiseActor + yield attachTab(client, chromeActors); + yield testGetTimeToSettle(client, chromeActors, + v => new Promise(resolve => setTimeout(() => resolve(v), 100))); + + let response = yield listTabs(client); + let targetTab = findTab(response.tabs, "test-promises-timetosettle"); + ok(targetTab, "Found our target tab."); + yield attachTab(client, targetTab); + + yield testGetTimeToSettle(client, targetTab, v => { + const debuggee = + DebuggerServer.getTestGlobal("test-promises-timetosettle"); + return new debuggee.Promise(resolve => setTimeout(() => resolve(v), 100)); + }); + + yield close(client); +}); + +function* testGetTimeToSettle(client, form, makePromise) { + let front = PromisesFront(client, form); + let resolution = "MyLittleSecret" + Math.random(); + let found = false; + + yield front.attach(); + yield front.listPromises(); + + let onNewPromise = new Promise(resolve => { + events.on(front, "promises-settled", promises => { + for (let p of promises) { + if (p.promiseState.state === "fulfilled" && + p.promiseState.value === resolution) { + let timeToSettle = Math.floor(p.promiseState.timeToSettle / 100) * 100; + ok(timeToSettle >= 100, + "Expect time to settle for resolved promise to be " + + "at least 100ms, got " + timeToSettle + "ms."); + found = true; + resolve(); + } else { + dump("Found non-target promise.\n"); + } + } + }); + }); + + let promise = makePromise(resolution); + + yield onNewPromise; + ok(found, "Found our new promise."); + yield front.detach(); + // Appease eslint + void promise; +} diff --git a/devtools/server/tests/unit/test_protocolSpec.js b/devtools/server/tests/unit/test_protocolSpec.js new file mode 100644 index 000000000..cc0746387 --- /dev/null +++ b/devtools/server/tests/unit/test_protocolSpec.js @@ -0,0 +1,17 @@ +const run_test = Test(function* () { + initTestDebuggerServer(); + const connection = DebuggerServer.connectPipe(); + const client = Async(new DebuggerClient(connection)); + + yield client.connect(); + + const response = yield client.request({ + to: "root", + type: "protocolDescription" + }); + + assert(response.from == "root"); + assert(typeof (response.types) === "object"); + + yield client.close(); +}); diff --git a/devtools/server/tests/unit/test_protocol_abort.js b/devtools/server/tests/unit/test_protocol_abort.js new file mode 100644 index 000000000..bb25d1b2c --- /dev/null +++ b/devtools/server/tests/unit/test_protocol_abort.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Outstanding requests should be rejected when the connection aborts + * unexpectedly. + */ + +var protocol = require("devtools/shared/protocol"); +var {Arg, Option, RetVal} = protocol; +var events = require("sdk/event/core"); + +function simpleHello() { + return { + from: "root", + applicationType: "xpcshell-tests", + traits: [], + }; +} + +const rootSpec = protocol.generateActorSpec({ + typeName: "root", + + methods: { + simpleReturn: { + response: { value: RetVal() } + } + } +}); + +var RootActor = protocol.ActorClassWithSpec(rootSpec, { + typeName: "root", + initialize: function (conn) { + protocol.Actor.prototype.initialize.call(this, conn); + // Root actor owns itself. + this.manage(this); + this.actorID = "root"; + this.sequence = 0; + }, + + sayHello: simpleHello, + + simpleReturn: function () { + return this.sequence++; + } +}); + +var RootFront = protocol.FrontClassWithSpec(rootSpec, { + initialize: function (client) { + this.actorID = "root"; + protocol.Front.prototype.initialize.call(this, client); + // Root owns itself. + this.manage(this); + } +}); + +function run_test() { + DebuggerServer.createRootActor = RootActor; + DebuggerServer.init(); + + let trace = connectPipeTracing(); + let client = new DebuggerClient(trace); + let rootClient; + + client.connect().then(([applicationType, traits]) => { + rootClient = RootFront(client); + + rootClient.simpleReturn().then(() => { + ok(false, "Connection was aborted, request shouldn't resolve"); + do_test_finished(); + }, e => { + let error = e.toString(); + ok(true, "Connection was aborted, request rejected correctly"); + ok(error.includes("Request stack:"), "Error includes request stack"); + ok(error.includes("test_protocol_abort.js"), "Stack includes this test"); + do_test_finished(); + }); + + trace.close(); + }); + + do_test_pending(); +} diff --git a/devtools/server/tests/unit/test_protocol_async.js b/devtools/server/tests/unit/test_protocol_async.js new file mode 100644 index 000000000..75f053863 --- /dev/null +++ b/devtools/server/tests/unit/test_protocol_async.js @@ -0,0 +1,184 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Make sure we get replies in the same order that we sent their + * requests even when earlier requests take several event ticks to + * complete. + */ + +var protocol = require("devtools/shared/protocol"); +var {Arg, Option, RetVal} = protocol; +var events = require("sdk/event/core"); + +function simpleHello() { + return { + from: "root", + applicationType: "xpcshell-tests", + traits: [], + }; +} + +const rootSpec = protocol.generateActorSpec({ + typeName: "root", + + methods: { + simpleReturn: { + response: { value: RetVal() }, + }, + promiseReturn: { + request: { toWait: Arg(0, "number") }, + response: { value: RetVal("number") }, + }, + simpleThrow: { + response: { value: RetVal("number") } + }, + promiseThrow: { + response: { value: RetVal("number") }, + } + } +}); + +var RootActor = protocol.ActorClassWithSpec(rootSpec, { + initialize: function (conn) { + protocol.Actor.prototype.initialize.call(this, conn); + // Root actor owns itself. + this.manage(this); + this.actorID = "root"; + this.sequence = 0; + }, + + sayHello: simpleHello, + + simpleReturn: function () { + return this.sequence++; + }, + + promiseReturn: function (toWait) { + // Guarantee that this resolves after simpleReturn returns. + let deferred = promise.defer(); + let sequence = this.sequence++; + + // Wait until the number of requests specified by toWait have + // happened, to test queuing. + let check = () => { + if ((this.sequence - sequence) < toWait) { + do_execute_soon(check); + return; + } + deferred.resolve(sequence); + }; + do_execute_soon(check); + + return deferred.promise; + }, + + simpleThrow: function () { + throw new Error(this.sequence++); + }, + + promiseThrow: function () { + // Guarantee that this resolves after simpleReturn returns. + let deferred = promise.defer(); + let sequence = this.sequence++; + // This should be enough to force a failure if the code is broken. + do_timeout(150, () => { + deferred.reject(sequence++); + }); + return deferred.promise; + } +}); + +var RootFront = protocol.FrontClassWithSpec(rootSpec, { + initialize: function (client) { + this.actorID = "root"; + protocol.Front.prototype.initialize.call(this, client); + // Root owns itself. + this.manage(this); + } +}); + +function run_test() +{ + DebuggerServer.createRootActor = RootActor; + DebuggerServer.init(); + + let trace = connectPipeTracing(); + let client = new DebuggerClient(trace); + let rootClient; + + client.connect().then(([applicationType, traits]) => { + rootClient = RootFront(client); + + let calls = []; + let sequence = 0; + + // Execute a call that won't finish processing until 2 + // more calls have happened + calls.push(rootClient.promiseReturn(2).then(ret => { + do_check_eq(sequence, 0); // Check right return order + do_check_eq(ret, sequence++); // Check request handling order + })); + + // Put a few requests into the backlog + + calls.push(rootClient.simpleReturn().then(ret => { + do_check_eq(sequence, 1); // Check right return order + do_check_eq(ret, sequence++); // Check request handling order + })); + + calls.push(rootClient.simpleReturn().then(ret => { + do_check_eq(sequence, 2); // Check right return order + do_check_eq(ret, sequence++); // Check request handling order + })); + + calls.push(rootClient.simpleThrow().then(() => { + do_check_true(false, "simpleThrow shouldn't succeed!"); + }, error => { + do_check_eq(sequence++, 3); // Check right return order + })); + + // While packets are sent in the correct order, rejection handlers + // registered in "Promise.jsm" may be invoked later than fulfillment + // handlers, meaning that we can't check the actual order with certainty. + let deferAfterRejection = promise.defer(); + + calls.push(rootClient.promiseThrow().then(() => { + do_check_true(false, "promiseThrow shouldn't succeed!"); + }, error => { + do_check_eq(sequence++, 4); // Check right return order + do_check_true(true, "simple throw should throw"); + deferAfterRejection.resolve(); + })); + + calls.push(rootClient.simpleReturn().then(ret => { + return deferAfterRejection.promise.then(function () { + do_check_eq(sequence, 5); // Check right return order + do_check_eq(ret, sequence++); // Check request handling order + }); + })); + + // Break up the backlog with a long request that waits + // for another simpleReturn before completing + calls.push(rootClient.promiseReturn(1).then(ret => { + return deferAfterRejection.promise.then(function () { + do_check_eq(sequence, 6); // Check right return order + do_check_eq(ret, sequence++); // Check request handling order + }); + })); + + calls.push(rootClient.simpleReturn().then(ret => { + return deferAfterRejection.promise.then(function () { + do_check_eq(sequence, 7); // Check right return order + do_check_eq(ret, sequence++); // Check request handling order + }); + })); + + promise.all(calls).then(() => { + client.close().then(() => { + do_test_finished(); + }); + }); + }); + do_test_pending(); +} diff --git a/devtools/server/tests/unit/test_protocol_children.js b/devtools/server/tests/unit/test_protocol_children.js new file mode 100644 index 000000000..67773ebef --- /dev/null +++ b/devtools/server/tests/unit/test_protocol_children.js @@ -0,0 +1,559 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test simple requests using the protocol helpers. + */ +var protocol = require("devtools/shared/protocol"); +var {preEvent, types, Arg, Option, RetVal} = protocol; + +var events = require("sdk/event/core"); + +function simpleHello() { + return { + from: "root", + applicationType: "xpcshell-tests", + traits: [], + }; +} + +var testTypes = {}; + +// Predeclaring the actor type so that it can be used in the +// implementation of the child actor. +types.addActorType("childActor"); + +const childSpec = protocol.generateActorSpec({ + typeName: "childActor", + + events: { + "event1" : { + a: Arg(0), + b: Arg(1), + c: Arg(2) + }, + "event2" : { + a: Arg(0), + b: Arg(1), + c: Arg(2) + }, + "named-event": { + type: "namedEvent", + a: Arg(0), + b: Arg(1), + c: Arg(2) + }, + "object-event": { + type: "objectEvent", + detail: Arg(0, "childActor#detail1"), + }, + "array-object-event": { + type: "arrayObjectEvent", + detail: Arg(0, "array:childActor#detail2"), + } + }, + + methods: { + echo: { + request: { str: Arg(0) }, + response: { str: RetVal("string") }, + }, + getDetail1: { + // This also exercises return-value-as-packet. + response: RetVal("childActor#detail1"), + }, + getDetail2: { + // This also exercises return-value-as-packet. + response: RetVal("childActor#detail2"), + }, + getIDDetail: { + response: { + idDetail: RetVal("childActor#actorid") + } + }, + getIntArray: { + request: { inputArray: Arg(0, "array:number") }, + response: RetVal("array:number") + }, + getSibling: { + request: { id: Arg(0) }, + response: { sibling: RetVal("childActor") } + }, + emitEvents: { + response: { value: "correct response" }, + }, + release: { + release: true + } + } +}); + +var ChildActor = protocol.ActorClassWithSpec(childSpec, { + // Actors returned by this actor should be owned by the root actor. + marshallPool: function () { return this.parent(); }, + + toString: function () { return "[ChildActor " + this.childID + "]"; }, + + initialize: function (conn, id) { + protocol.Actor.prototype.initialize.call(this, conn); + this.childID = id; + }, + + destroy: function () { + protocol.Actor.prototype.destroy.call(this); + this.destroyed = true; + }, + + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + return { + actor: this.actorID, + childID: this.childID, + detail: detail + }; + }, + + echo: function (str) { + return str; + }, + + getDetail1: function () { + return this; + }, + + getDetail2: function () { + return this; + }, + + getIDDetail: function () { + return this; + }, + + getIntArray: function (inputArray) { + // Test that protocol.js converts an iterator to an array. + let f = function* () { + for (let i of inputArray) { + yield 2 * i; + } + }; + return f(); + }, + + getSibling: function (id) { + return this.parent().getChild(id); + }, + + emitEvents: function () { + events.emit(this, "event1", 1, 2, 3); + events.emit(this, "event2", 4, 5, 6); + events.emit(this, "named-event", 1, 2, 3); + events.emit(this, "object-event", this); + events.emit(this, "array-object-event", [this]); + }, + + release: function () { }, +}); + +var ChildFront = protocol.FrontClassWithSpec(childSpec, { + initialize: function (client, form) { + protocol.Front.prototype.initialize.call(this, client, form); + }, + + destroy: function () { + this.destroyed = true; + protocol.Front.prototype.destroy.call(this); + }, + + marshallPool: function () { return this.parent(); }, + + toString: function () { return "[child front " + this.childID + "]"; }, + + form: function (form, detail) { + if (detail === "actorid") { + return; + } + this.childID = form.childID; + this.detail = form.detail; + }, + + onEvent1: preEvent("event1", function (a, b, c) { + this.event1arg3 = c; + }), + + onEvent2a: preEvent("event2", function (a, b, c) { + return promise.resolve().then(() => this.event2arg3 = c); + }), + + onEvent2b: preEvent("event2", function (a, b, c) { + this.event2arg2 = b; + }), +}); + +types.addDictType("manyChildrenDict", { + child5: "childActor", + more: "array:childActor", +}); + +types.addLifetime("temp", "_temporaryHolder"); + +const rootSpec = protocol.generateActorSpec({ + typeName: "root", + + methods: { + getChild: { + request: { str: Arg(0) }, + response: { actor: RetVal("childActor") }, + }, + getChildren: { + request: { ids: Arg(0, "array:string") }, + response: { children: RetVal("array:childActor") }, + }, + getChildren2: { + request: { ids: Arg(0, "array:childActor") }, + response: { children: RetVal("array:childActor") }, + }, + getManyChildren: { + response: RetVal("manyChildrenDict") + }, + getTemporaryChild: { + request: { id: Arg(0) }, + response: { child: RetVal("temp:childActor") } + }, + clearTemporaryChildren: {} + } +}); + +var rootActor = null; +var RootActor = protocol.ActorClassWithSpec(rootSpec, { + toString: function () { return "[root actor]"; }, + + initialize: function (conn) { + rootActor = this; + this.actorID = "root"; + this._children = {}; + protocol.Actor.prototype.initialize.call(this, conn); + // Root actor owns itself. + this.manage(this); + }, + + sayHello: simpleHello, + + getChild: function (id) { + if (id in this._children) { + return this._children[id]; + } + let child = new ChildActor(this.conn, id); + this._children[id] = child; + return child; + }, + + getChildren: function (ids) { + return ids.map(id => this.getChild(id)); + }, + + getChildren2: function (ids) { + let f = function* () { + for (let c of ids) { + yield c; + } + }; + return f(); + }, + + getManyChildren: function () { + return { + foo: "bar", // note that this isn't in the specialization array. + child5: this.getChild("child5"), + more: [ this.getChild("child6"), this.getChild("child7") ] + }; + }, + + // This should remind you of a pause actor. + getTemporaryChild: function (id) { + if (!this._temporaryHolder) { + this._temporaryHolder = this.manage(new protocol.Actor(this.conn)); + } + return new ChildActor(this.conn, id); + }, + + clearTemporaryChildren: function (id) { + if (!this._temporaryHolder) { + return; + } + this._temporaryHolder.destroy(); + delete this._temporaryHolder; + } +}); + +var RootFront = protocol.FrontClassWithSpec(rootSpec, { + toString: function () { return "[root front]"; }, + initialize: function (client) { + this.actorID = "root"; + protocol.Front.prototype.initialize.call(this, client); + // Root actor owns itself. + this.manage(this); + }, + + getTemporaryChild: protocol.custom(function (id) { + if (!this._temporaryHolder) { + this._temporaryHolder = protocol.Front(this.conn); + this._temporaryHolder.actorID = this.actorID + "_temp"; + this._temporaryHolder = this.manage(this._temporaryHolder); + } + return this._getTemporaryChild(id); + }, { + impl: "_getTemporaryChild" + }), + + clearTemporaryChildren: protocol.custom(function () { + if (!this._temporaryHolder) { + return promise.resolve(undefined); + } + this._temporaryHolder.destroy(); + delete this._temporaryHolder; + return this._clearTemporaryChildren(); + }, { + impl: "_clearTemporaryChildren" + }) +}); + +function run_test() +{ + DebuggerServer.createRootActor = (conn => { + return RootActor(conn); + }); + DebuggerServer.init(); + + let trace = connectPipeTracing(); + let client = new DebuggerClient(trace); + client.connect().then(([applicationType, traits]) => { + trace.expectReceive({"from":"<actorid>", "applicationType":"xpcshell-tests", "traits":[]}); + do_check_eq(applicationType, "xpcshell-tests"); + + let rootFront = RootFront(client); + let childFront = null; + + let expectRootChildren = size => { + do_check_eq(rootActor._poolMap.size, size + 1); + do_check_eq(rootFront._poolMap.size, size + 1); + if (childFront) { + do_check_eq(childFront._poolMap.size, 0); + } + }; + + rootFront.getChild("child1").then(ret => { + trace.expectSend({"type":"getChild", "str":"child1", "to":"<actorid>"}); + trace.expectReceive({"actor":"<actorid>", "from":"<actorid>"}); + + childFront = ret; + do_check_true(childFront instanceof ChildFront); + do_check_eq(childFront.childID, "child1"); + expectRootChildren(1); + }).then(() => { + // Request the child again, make sure the same is returned. + return rootFront.getChild("child1"); + }).then(ret => { + trace.expectSend({"type":"getChild", "str":"child1", "to":"<actorid>"}); + trace.expectReceive({"actor":"<actorid>", "from":"<actorid>"}); + + expectRootChildren(1); + do_check_true(ret === childFront); + }).then(() => { + return childFront.echo("hello"); + }).then(ret => { + trace.expectSend({"type":"echo", "str":"hello", "to":"<actorid>"}); + trace.expectReceive({"str":"hello", "from":"<actorid>"}); + + do_check_eq(ret, "hello"); + }).then(() => { + return childFront.getDetail1(); + }).then(ret => { + trace.expectSend({"type":"getDetail1", "to":"<actorid>"}); + trace.expectReceive({"actor":"<actorid>", "childID":"child1", "detail":"detail1", "from":"<actorid>"}); + do_check_true(ret === childFront); + do_check_eq(childFront.detail, "detail1"); + }).then(() => { + return childFront.getDetail2(); + }).then(ret => { + trace.expectSend({"type":"getDetail2", "to":"<actorid>"}); + trace.expectReceive({"actor":"<actorid>", "childID":"child1", "detail":"detail2", "from":"<actorid>"}); + do_check_true(ret === childFront); + do_check_eq(childFront.detail, "detail2"); + }).then(() => { + return childFront.getIDDetail(); + }).then(ret => { + trace.expectSend({"type":"getIDDetail", "to":"<actorid>"}); + trace.expectReceive({"idDetail": childFront.actorID, "from":"<actorid>"}); + do_check_true(ret === childFront); + }).then(() => { + return childFront.getSibling("siblingID"); + }).then(ret => { + trace.expectSend({"type":"getSibling", "id":"siblingID", "to":"<actorid>"}); + trace.expectReceive({"sibling":{"actor":"<actorid>", "childID":"siblingID"}, "from":"<actorid>"}); + + expectRootChildren(2); + }).then(ret => { + return rootFront.getTemporaryChild("temp1").then(temp1 => { + trace.expectSend({"type":"getTemporaryChild", "id":"temp1", "to":"<actorid>"}); + trace.expectReceive({"child":{"actor":"<actorid>", "childID":"temp1"}, "from":"<actorid>"}); + + // At this point we expect two direct children, plus the temporary holder + // which should hold 1 itself. + do_check_eq(rootActor._temporaryHolder.__poolMap.size, 1); + do_check_eq(rootFront._temporaryHolder.__poolMap.size, 1); + + expectRootChildren(3); + return rootFront.getTemporaryChild("temp2").then(temp2 => { + trace.expectSend({"type":"getTemporaryChild", "id":"temp2", "to":"<actorid>"}); + trace.expectReceive({"child":{"actor":"<actorid>", "childID":"temp2"}, "from":"<actorid>"}); + + // Same amount of direct children, and an extra in the temporary holder. + expectRootChildren(3); + do_check_eq(rootActor._temporaryHolder.__poolMap.size, 2); + do_check_eq(rootFront._temporaryHolder.__poolMap.size, 2); + + // Get the children of the temporary holder... + let checkActors = rootActor._temporaryHolder.__poolMap.values(); + let checkFronts = rootFront._temporaryHolder.__poolMap.values(); + + // Now release the temporary holders and expect them to drop again. + return rootFront.clearTemporaryChildren().then(() => { + trace.expectSend({"type":"clearTemporaryChildren", "to":"<actorid>"}); + trace.expectReceive({"from":"<actorid>"}); + + expectRootChildren(2); + do_check_false(!!rootActor._temporaryHolder); + do_check_false(!!rootFront._temporaryHolder); + for (let checkActor of checkActors) { + do_check_true(checkActor.destroyed); + do_check_true(checkActor.destroyed); + } + }); + }); + }); + }).then(ret => { + return rootFront.getChildren(["child1", "child2"]); + }).then(ret => { + trace.expectSend({"type":"getChildren", "ids":["child1", "child2"], "to":"<actorid>"}); + trace.expectReceive({"children":[{"actor":"<actorid>", "childID":"child1"}, {"actor":"<actorid>", "childID":"child2"}], "from":"<actorid>"}); + + expectRootChildren(3); + do_check_true(ret[0] === childFront); + do_check_true(ret[1] !== childFront); + do_check_true(ret[1] instanceof ChildFront); + + // On both children, listen to events. We're only + // going to trigger events on the first child, so an event + // triggered on the second should cause immediate failures. + + let set = new Set(["event1", "event2", "named-event", "object-event", "array-object-event"]); + + childFront.on("event1", (a, b, c) => { + do_check_eq(a, 1); + do_check_eq(b, 2); + do_check_eq(c, 3); + // Verify that the pre-event handler was called. + do_check_eq(childFront.event1arg3, 3); + set.delete("event1"); + }); + childFront.on("event2", (a, b, c) => { + do_check_eq(a, 4); + do_check_eq(b, 5); + do_check_eq(c, 6); + // Verify that the async pre-event handler was called, + // setting the property before this handler was called. + do_check_eq(childFront.event2arg3, 6); + // And check that the sync preEvent with the same name is also + // executed + do_check_eq(childFront.event2arg2, 5); + set.delete("event2"); + }); + childFront.on("named-event", (a, b, c) => { + do_check_eq(a, 1); + do_check_eq(b, 2); + do_check_eq(c, 3); + set.delete("named-event"); + }); + childFront.on("object-event", (obj) => { + do_check_true(obj === childFront); + do_check_eq(childFront.detail, "detail1"); + set.delete("object-event"); + }); + childFront.on("array-object-event", (array) => { + do_check_true(array[0] === childFront); + do_check_eq(childFront.detail, "detail2"); + set.delete("array-object-event"); + }); + + let fail = function () { + do_throw("Unexpected event"); + }; + ret[1].on("event1", fail); + ret[1].on("event2", fail); + ret[1].on("named-event", fail); + ret[1].on("object-event", fail); + ret[1].on("array-object-event", fail); + + return childFront.emitEvents().then(() => { + trace.expectSend({"type":"emitEvents", "to":"<actorid>"}); + trace.expectReceive({"type":"event1", "a":1, "b":2, "c":3, "from":"<actorid>"}); + trace.expectReceive({"type":"event2", "a":4, "b":5, "c":6, "from":"<actorid>"}); + trace.expectReceive({"type":"namedEvent", "a":1, "b":2, "c":3, "from":"<actorid>"}); + trace.expectReceive({"type":"objectEvent", "detail":{"actor":"<actorid>", "childID":"child1", "detail":"detail1"}, "from":"<actorid>"}); + trace.expectReceive({"type":"arrayObjectEvent", "detail":[{"actor":"<actorid>", "childID":"child1", "detail":"detail2"}], "from":"<actorid>"}); + trace.expectReceive({"value":"correct response", "from":"<actorid>"}); + + + do_check_eq(set.size, 0); + }); + }).then(ret => { + return rootFront.getManyChildren(); + }).then(ret => { + trace.expectSend({"type":"getManyChildren", "to":"<actorid>"}); + trace.expectReceive({"foo":"bar", "child5":{"actor":"<actorid>", "childID":"child5"}, "more":[{"actor":"<actorid>", "childID":"child6"}, {"actor":"<actorid>", "childID":"child7"}], "from":"<actorid>"}); + + // Check all the crazy stuff we did in getManyChildren + do_check_eq(ret.foo, "bar"); + do_check_eq(ret.child5.childID, "child5"); + do_check_eq(ret.more[0].childID, "child6"); + do_check_eq(ret.more[1].childID, "child7"); + }).then(() => { + // Test accepting a generator. + let f = function* () { + for (let i of [1, 2, 3, 4, 5]) { + yield i; + } + }; + return childFront.getIntArray(f()); + }).then((ret) => { + do_check_eq(ret.length, 5); + let expected = [2, 4, 6, 8, 10]; + for (let i = 0; i < 5; ++i) { + do_check_eq(ret[i], expected[i]); + } + }).then(() => { + return rootFront.getChildren(["child1", "child2"]); + }).then(ids => { + let f = function* () { + for (let id of ids) { + yield id; + } + }; + return rootFront.getChildren2(f()); + }).then(ret => { + do_check_eq(ret.length, 2); + do_check_true(ret[0] === childFront); + do_check_true(ret[1] !== childFront); + do_check_true(ret[1] instanceof ChildFront); + }).then(() => { + client.close().then(() => { + do_test_finished(); + }); + }).then(null, err => { + do_report_unexpected_exception(err, "Failure executing test"); + }); + }); + do_test_pending(); +} diff --git a/devtools/server/tests/unit/test_protocol_formtype.js b/devtools/server/tests/unit/test_protocol_formtype.js new file mode 100644 index 000000000..27ac0bee9 --- /dev/null +++ b/devtools/server/tests/unit/test_protocol_formtype.js @@ -0,0 +1,177 @@ +var protocol = require("devtools/shared/protocol"); +var {Arg, Option, RetVal} = protocol; + +protocol.types.addActorType("child"); +protocol.types.addActorType("root"); + +const childSpec = protocol.generateActorSpec({ + typeName: "child", + + methods: { + getChild: { + response: RetVal("child") + } + } +}); + +// The child actor doesn't provide a form description +var ChildActor = protocol.ActorClassWithSpec(childSpec, { + initialize(conn) { + protocol.Actor.prototype.initialize.call(this, conn); + }, + + form(detail) { + return { + actor: this.actorID, + extra: "extra" + }; + }, + + getChild: function () { + return this; + } +}); + +var ChildFront = protocol.FrontClassWithSpec(childSpec, { + initialize(client) { + protocol.Front.prototype.initialize.call(this, client); + }, + + form(v, ctx, detail) { + this.extra = v.extra; + } +}); + +const rootSpec = protocol.generateActorSpec({ + typeName: "root", + + // Basic form type, relies on implicit DictType creation + formType: { + childActor: "child" + }, + + // This detail uses explicit DictType creation + "formType#detail1": protocol.types.addDictType("RootActorFormTypeDetail1", { + detailItem: "child" + }), + + // This detail a string type. + "formType#actorid": "string", + + methods: { + getDefault: { + response: RetVal("root") + }, + getDetail1: { + response: RetVal("root#detail1") + }, + getDetail2: { + response: { + item: RetVal("root#actorid") + } + }, + getUnknownDetail: { + response: RetVal("root#unknownDetail") + } + } +}); + +// The root actor does provide a form description. +var RootActor = protocol.ActorClassWithSpec(rootSpec, { + initialize(conn) { + protocol.Actor.prototype.initialize.call(this, conn); + this.manage(this); + this.child = new ChildActor(); + }, + + sayHello() { + return { + from: "root", + applicationType: "xpcshell-tests", + traits: [] + }; + }, + + form(detail) { + if (detail === "detail1") { + return { + actor: this.actorID, + detailItem: this.child + }; + } else if (detail === "actorid") { + return this.actorID; + } + + return { + actor: this.actorID, + childActor: this.child + }; + }, + + getDefault: function () { + return this; + }, + + getDetail1: function () { + return this; + }, + + getDetail2: function () { + return this; + }, + + getUnknownDetail: function () { + return this; + } +}); + +var RootFront = protocol.FrontClassWithSpec(rootSpec, { + initialize(client) { + this.actorID = "root"; + protocol.Front.prototype.initialize.call(this, client); + + // Root owns itself. + this.manage(this); + }, + + form(v, ctx, detail) { + this.lastForm = v; + } +}); + +const run_test = Test(function* () { + DebuggerServer.createRootActor = (conn => { + return RootActor(conn); + }); + DebuggerServer.init(); + + const connection = DebuggerServer.connectPipe(); + const conn = new DebuggerClient(connection); + const client = Async(conn); + + yield client.connect(); + + let rootFront = RootFront(conn); + + // Trigger some methods that return forms. + let retval = yield rootFront.getDefault(); + do_check_true(retval instanceof RootFront); + do_check_true(rootFront.lastForm.childActor instanceof ChildFront); + + retval = yield rootFront.getDetail1(); + do_check_true(retval instanceof RootFront); + do_check_true(rootFront.lastForm.detailItem instanceof ChildFront); + + retval = yield rootFront.getDetail2(); + do_check_true(retval instanceof RootFront); + do_check_true(typeof (rootFront.lastForm) === "string"); + + // getUnknownDetail should fail, since no typeName is specified. + try { + yield rootFront.getUnknownDetail(); + do_check_true(false); + } catch (ex) { + } + + yield client.close(); +}); diff --git a/devtools/server/tests/unit/test_protocol_longstring.js b/devtools/server/tests/unit/test_protocol_longstring.js new file mode 100644 index 000000000..c37f4251e --- /dev/null +++ b/devtools/server/tests/unit/test_protocol_longstring.js @@ -0,0 +1,218 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test simple requests using the protocol helpers. + */ +var protocol = require("devtools/shared/protocol"); +var {RetVal, Arg, Option} = protocol; +var events = require("sdk/event/core"); +var {LongStringActor} = require("devtools/server/actors/string"); + +// The test implicitly relies on this. +require("devtools/shared/fronts/string"); + +function simpleHello() { + return { + from: "root", + applicationType: "xpcshell-tests", + traits: [], + }; +} + +DebuggerServer.LONG_STRING_LENGTH = DebuggerServer.LONG_STRING_INITIAL_LENGTH = DebuggerServer.LONG_STRING_READ_LENGTH = 5; + +var SHORT_STR = "abc"; +var LONG_STR = "abcdefghijklmnop"; + +var rootActor = null; + +const rootSpec = protocol.generateActorSpec({ + typeName: "root", + + events: { + "string-event": { + str: Arg(0, "longstring") + } + }, + + methods: { + shortString: { + response: { value: RetVal("longstring") }, + }, + longString: { + response: { value: RetVal("longstring") }, + }, + emitShortString: { + oneway: true, + }, + emitLongString: { + oneway: true, + } + } +}); + +var RootActor = protocol.ActorClassWithSpec(rootSpec, { + initialize: function (conn) { + rootActor = this; + protocol.Actor.prototype.initialize.call(this, conn); + // Root actor owns itself. + this.manage(this); + this.actorID = "root"; + }, + + sayHello: simpleHello, + + shortString: function () { + return new LongStringActor(this.conn, SHORT_STR); + }, + + longString: function () { + return new LongStringActor(this.conn, LONG_STR); + }, + + emitShortString: function () { + events.emit(this, "string-event", new LongStringActor(this.conn, SHORT_STR)); + }, + + emitLongString: function () { + events.emit(this, "string-event", new LongStringActor(this.conn, LONG_STR)); + }, +}); + +var RootFront = protocol.FrontClassWithSpec(rootSpec, { + initialize: function (client) { + this.actorID = "root"; + protocol.Front.prototype.initialize.call(this, client); + // Root owns itself. + this.manage(this); + } +}); + +function run_test() +{ + DebuggerServer.createRootActor = (conn => { + return RootActor(conn); + }); + + DebuggerServer.init(); + let trace = connectPipeTracing(); + let client = new DebuggerClient(trace); + let rootClient; + + let strfront = null; + + let expectRootChildren = function (size) { + do_check_eq(rootActor.__poolMap.size, size + 1); + do_check_eq(rootClient.__poolMap.size, size + 1); + }; + + + client.connect().then(([applicationType, traits]) => { + rootClient = RootFront(client); + + // Root actor has no children yet. + expectRootChildren(0); + + trace.expectReceive({"from":"<actorid>", "applicationType":"xpcshell-tests", "traits":[]}); + do_check_eq(applicationType, "xpcshell-tests"); + rootClient.shortString().then(ret => { + trace.expectSend({"type":"shortString", "to":"<actorid>"}); + trace.expectReceive({"value":"abc", "from":"<actorid>"}); + + // Should only own the one reference (itself) at this point. + expectRootChildren(0); + strfront = ret; + }).then(() => { + return strfront.string(); + }).then(ret => { + do_check_eq(ret, SHORT_STR); + }).then(() => { + return rootClient.longString(); + }).then(ret => { + trace.expectSend({"type":"longString", "to":"<actorid>"}); + trace.expectReceive({"value":{"type":"longString", "actor":"<actorid>", "length":16, "initial":"abcde"}, "from":"<actorid>"}); + + strfront = ret; + // Should own a reference to itself and an extra string now. + expectRootChildren(1); + }).then(() => { + return strfront.string(); + }).then(ret => { + trace.expectSend({"type":"substring", "start":5, "end":10, "to":"<actorid>"}); + trace.expectReceive({"substring":"fghij", "from":"<actorid>"}); + trace.expectSend({"type":"substring", "start":10, "end":15, "to":"<actorid>"}); + trace.expectReceive({"substring":"klmno", "from":"<actorid>"}); + trace.expectSend({"type":"substring", "start":15, "end":20, "to":"<actorid>"}); + trace.expectReceive({"substring":"p", "from":"<actorid>"}); + + do_check_eq(ret, LONG_STR); + }).then(() => { + return strfront.release(); + }).then(() => { + trace.expectSend({"type":"release", "to":"<actorid>"}); + trace.expectReceive({"from":"<actorid>"}); + + // That reference should be removed now. + expectRootChildren(0); + }).then(() => { + let deferred = promise.defer(); + rootClient.once("string-event", (str) => { + trace.expectSend({"type":"emitShortString", "to":"<actorid>"}); + trace.expectReceive({"type":"string-event", "str":"abc", "from":"<actorid>"}); + + do_check_true(!!str); + strfront = str; + // Shouldn't generate any new references + expectRootChildren(0); + // will generate no packets. + strfront.string().then((value) => { deferred.resolve(value); }); + }); + rootClient.emitShortString(); + return deferred.promise; + }).then(value => { + do_check_eq(value, SHORT_STR); + }).then(() => { + // Will generate no packets + return strfront.release(); + }).then(() => { + let deferred = promise.defer(); + rootClient.once("string-event", (str) => { + trace.expectSend({"type":"emitLongString", "to":"<actorid>"}); + trace.expectReceive({"type":"string-event", "str":{"type":"longString", "actor":"<actorid>", "length":16, "initial":"abcde"}, "from":"<actorid>"}); + + do_check_true(!!str); + // Should generate one new reference + expectRootChildren(1); + strfront = str; + strfront.string().then((value) => { + trace.expectSend({"type":"substring", "start":5, "end":10, "to":"<actorid>"}); + trace.expectReceive({"substring":"fghij", "from":"<actorid>"}); + trace.expectSend({"type":"substring", "start":10, "end":15, "to":"<actorid>"}); + trace.expectReceive({"substring":"klmno", "from":"<actorid>"}); + trace.expectSend({"type":"substring", "start":15, "end":20, "to":"<actorid>"}); + trace.expectReceive({"substring":"p", "from":"<actorid>"}); + + deferred.resolve(value); + }); + }); + rootClient.emitLongString(); + return deferred.promise; + }).then(value => { + do_check_eq(value, LONG_STR); + }).then(() => { + return strfront.release(); + }).then(() => { + trace.expectSend({"type":"release", "to":"<actorid>"}); + trace.expectReceive({"from":"<actorid>"}); + expectRootChildren(0); + }).then(() => { + client.close().then(() => { + do_test_finished(); + }); + }).then(null, err => { + do_report_unexpected_exception(err, "Failure executing test"); + }); + }); + do_test_pending(); +} diff --git a/devtools/server/tests/unit/test_protocol_simple.js b/devtools/server/tests/unit/test_protocol_simple.js new file mode 100644 index 000000000..c85003954 --- /dev/null +++ b/devtools/server/tests/unit/test_protocol_simple.js @@ -0,0 +1,319 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test simple requests using the protocol helpers. + */ + +var protocol = require("devtools/shared/protocol"); +var {Arg, Option, RetVal} = protocol; +var events = require("sdk/event/core"); + +function simpleHello() { + return { + from: "root", + applicationType: "xpcshell-tests", + traits: [], + }; +} + +const rootSpec = protocol.generateActorSpec({ + typeName: "root", + + events: { + "oneway": { a: Arg(0) }, + "falsyOptions": { + zero: Option(0), + farce: Option(0) + } + }, + + methods: { + simpleReturn: { + response: { value: RetVal() }, + }, + promiseReturn: { + response: { value: RetVal("number") }, + }, + simpleArgs: { + request: { + firstArg: Arg(0), + secondArg: Arg(1), + }, + response: RetVal() + }, + nestedArgs: { + request: { + firstArg: Arg(0), + nest: { + secondArg: Arg(1), + nest: { + thirdArg: Arg(2) + } + } + }, + response: RetVal() + }, + optionArgs: { + request: { + option1: Option(0), + option2: Option(0) + }, + response: RetVal() + }, + optionalArgs: { + request: { + a: Arg(0), + b: Arg(1, "nullable:number") + }, + response: { + value: RetVal("number") + }, + }, + arrayArgs: { + request: { + a: Arg(0, "array:number") + }, + response: { + arrayReturn: RetVal("array:number") + }, + }, + nestedArrayArgs: { + request: { a: Arg(0, "array:array:number") }, + response: { value: RetVal("array:array:number") }, + }, + renamedEcho: { + request: { + type: "echo", + a: Arg(0), + }, + response: { + value: RetVal("string") + }, + }, + testOneWay: { + request: { a: Arg(0) }, + oneway: true + }, + emitFalsyOptions: { + oneway: true + } + } +}); + +var RootActor = protocol.ActorClassWithSpec(rootSpec, { + initialize: function (conn) { + protocol.Actor.prototype.initialize.call(this, conn); + // Root actor owns itself. + this.manage(this); + this.actorID = "root"; + }, + + sayHello: simpleHello, + + simpleReturn: function () { + return 1; + }, + + promiseReturn: function () { + return promise.resolve(1); + }, + + simpleArgs: function (a, b) { + return { firstResponse: a + 1, secondResponse: b + 1 }; + }, + + nestedArgs: function (a, b, c) { + return { a: a, b: b, c: c }; + }, + + optionArgs: function (options) { + return { option1: options.option1, option2: options.option2 }; + }, + + optionalArgs: function (a, b = 200) { + return b; + }, + + arrayArgs: function (a) { + return a; + }, + + nestedArrayArgs: function (a) { + return a; + }, + + /** + * Test that the 'type' part of the request packet works + * correctly when the type isn't the same as the method name + */ + renamedEcho: function (a) { + if (this.conn.currentPacket.type != "echo") { + return "goodbye"; + } + return a; + }, + + testOneWay: function (a) { + // Emit to show that we got this message, because there won't be a response. + events.emit(this, "oneway", a); + }, + + emitFalsyOptions: function () { + events.emit(this, "falsyOptions", { zero: 0, farce: false }); + } +}); + +var RootFront = protocol.FrontClassWithSpec(rootSpec, { + initialize: function (client) { + this.actorID = "root"; + protocol.Front.prototype.initialize.call(this, client); + // Root owns itself. + this.manage(this); + } +}); + +function run_test() +{ + DebuggerServer.createRootActor = (conn => { + return RootActor(conn); + }); + DebuggerServer.init(); + + check_except(() => { + let badActor = ActorClassWithSpec({}, { + missing: preEvent("missing-event", function () { + }) + }); + }); + + protocol.types.getType("array:array:array:number"); + protocol.types.getType("array:array:array:number"); + + check_except(() => protocol.types.getType("unknown")); + check_except(() => protocol.types.getType("array:unknown")); + check_except(() => protocol.types.getType("unknown:number")); + let trace = connectPipeTracing(); + let client = new DebuggerClient(trace); + let rootClient; + + client.connect().then(([applicationType, traits]) => { + trace.expectReceive({"from":"<actorid>", "applicationType":"xpcshell-tests", "traits":[]}); + do_check_eq(applicationType, "xpcshell-tests"); + + rootClient = RootFront(client); + + rootClient.simpleReturn().then(ret => { + trace.expectSend({"type":"simpleReturn", "to":"<actorid>"}); + trace.expectReceive({"value":1, "from":"<actorid>"}); + do_check_eq(ret, 1); + }).then(() => { + return rootClient.promiseReturn(); + }).then(ret => { + trace.expectSend({"type":"promiseReturn", "to":"<actorid>"}); + trace.expectReceive({"value":1, "from":"<actorid>"}); + do_check_eq(ret, 1); + }).then(() => { + // Missing argument should throw an exception + check_except(() => { + rootClient.simpleArgs(5); + }); + + return rootClient.simpleArgs(5, 10); + }).then(ret => { + trace.expectSend({"type":"simpleArgs", "firstArg":5, "secondArg":10, "to":"<actorid>"}); + trace.expectReceive({"firstResponse":6, "secondResponse":11, "from":"<actorid>"}); + do_check_eq(ret.firstResponse, 6); + do_check_eq(ret.secondResponse, 11); + }).then(() => { + return rootClient.nestedArgs(1, 2, 3); + }).then(ret => { + trace.expectSend({"type":"nestedArgs", "firstArg":1, "nest":{"secondArg":2, "nest":{"thirdArg":3}}, "to":"<actorid>"}); + trace.expectReceive({"a":1, "b":2, "c":3, "from":"<actorid>"}); + do_check_eq(ret.a, 1); + do_check_eq(ret.b, 2); + do_check_eq(ret.c, 3); + }).then(() => { + return rootClient.optionArgs({ + "option1": 5, + "option2": 10 + }); + }).then(ret => { + trace.expectSend({"type":"optionArgs", "option1":5, "option2":10, "to":"<actorid>"}); + trace.expectReceive({"option1":5, "option2":10, "from":"<actorid>"}); + do_check_eq(ret.option1, 5); + do_check_eq(ret.option2, 10); + }).then(() => { + return rootClient.optionArgs({}); + }).then(ret => { + trace.expectSend({"type":"optionArgs", "to":"<actorid>"}); + trace.expectReceive({"from":"<actorid>"}); + do_check_true(typeof (ret.option1) === "undefined"); + do_check_true(typeof (ret.option2) === "undefined"); + }).then(() => { + // Explicitly call an optional argument... + return rootClient.optionalArgs(5, 10); + }).then(ret => { + trace.expectSend({"type":"optionalArgs", "a":5, "b":10, "to":"<actorid>"}); + trace.expectReceive({"value":10, "from":"<actorid>"}); + do_check_eq(ret, 10); + }).then(() => { + // Now don't pass the optional argument, expect the default. + return rootClient.optionalArgs(5); + }).then(ret => { + trace.expectSend({"type":"optionalArgs", "a":5, "to":"<actorid>"}); + trace.expectReceive({"value":200, "from":"<actorid>"}); + do_check_eq(ret, 200); + }).then(ret => { + return rootClient.arrayArgs([0, 1, 2, 3, 4, 5]); + }).then(ret => { + trace.expectSend({"type":"arrayArgs", "a":[0, 1, 2, 3, 4, 5], "to":"<actorid>"}); + trace.expectReceive({"arrayReturn":[0, 1, 2, 3, 4, 5], "from":"<actorid>"}); + do_check_eq(ret[0], 0); + do_check_eq(ret[5], 5); + }).then(() => { + return rootClient.arrayArgs([[5]]); + }).then(ret => { + trace.expectSend({"type":"arrayArgs", "a":[[5]], "to":"<actorid>"}); + trace.expectReceive({"arrayReturn":[[5]], "from":"<actorid>"}); + do_check_eq(ret[0][0], 5); + }).then(() => { + return rootClient.renamedEcho("hello"); + }).then(str => { + trace.expectSend({"type":"echo", "a":"hello", "to":"<actorid>"}); + trace.expectReceive({"value":"hello", "from":"<actorid>"}); + + do_check_eq(str, "hello"); + + let deferred = promise.defer(); + rootClient.on("oneway", (response) => { + trace.expectSend({"type":"testOneWay", "a":"hello", "to":"<actorid>"}); + trace.expectReceive({"type":"oneway", "a":"hello", "from":"<actorid>"}); + + do_check_eq(response, "hello"); + deferred.resolve(); + }); + do_check_true(typeof (rootClient.testOneWay("hello")) === "undefined"); + return deferred.promise; + }).then(() => { + let deferred = promise.defer(); + rootClient.on("falsyOptions", res => { + trace.expectSend({"type":"emitFalsyOptions", "to":"<actorid>"}); + trace.expectReceive({"type":"falsyOptions", "farce":false, "zero": 0, "from":"<actorid>"}); + + do_check_true(res.zero === 0); + do_check_true(res.farce === false); + deferred.resolve(); + }); + rootClient.emitFalsyOptions(); + return deferred.promise; + }).then(() => { + client.close().then(() => { + do_test_finished(); + }); + }).then(null, err => { + do_report_unexpected_exception(err, "Failure executing test"); + }); + }); + do_test_pending(); +} diff --git a/devtools/server/tests/unit/test_protocol_stack.js b/devtools/server/tests/unit/test_protocol_stack.js new file mode 100644 index 000000000..a81f99a8e --- /dev/null +++ b/devtools/server/tests/unit/test_protocol_stack.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Client request stacks should span the entire process from before making the + * request to handling the reply from the server. The server frames are not + * included, nor can they be in most cases, since the server can be a remote + * device. + */ + +var protocol = require("devtools/shared/protocol"); +var {Arg, Option, RetVal} = protocol; +var events = require("sdk/event/core"); + +function simpleHello() { + return { + from: "root", + applicationType: "xpcshell-tests", + traits: [], + }; +} + +const rootSpec = protocol.generateActorSpec({ + typeName: "root", + + methods: { + simpleReturn: { + response: { value: RetVal() }, + } + } +}); + +var RootActor = protocol.ActorClassWithSpec(rootSpec, { + initialize: function (conn) { + protocol.Actor.prototype.initialize.call(this, conn); + // Root actor owns itself. + this.manage(this); + this.actorID = "root"; + this.sequence = 0; + }, + + sayHello: simpleHello, + + simpleReturn: function () { + return this.sequence++; + } +}); + +var RootFront = protocol.FrontClassWithSpec(rootSpec, { + initialize: function (client) { + this.actorID = "root"; + protocol.Front.prototype.initialize.call(this, client); + // Root owns itself. + this.manage(this); + } +}); + +function run_test() { + if (!Services.prefs.getBoolPref("javascript.options.asyncstack")) { + do_print("Async stacks are disabled."); + return; + } + + DebuggerServer.createRootActor = RootActor; + DebuggerServer.init(); + + let trace = connectPipeTracing(); + let client = new DebuggerClient(trace); + let rootClient; + + client.connect().then(function onConnect() { + rootClient = RootFront(client); + + rootClient.simpleReturn().then(() => { + let stack = Components.stack; + while (stack) { + do_print(stack.name); + if (stack.name == "onConnect") { + // Reached back to outer function before request + ok(true, "Complete stack"); + return; + } + stack = stack.asyncCaller || stack.caller; + } + ok(false, "Incomplete stack"); + }, () => { + ok(false, "Request failed unexpectedly"); + }).then(() => { + client.close().then(() => { + do_test_finished(); + }); + }); + }); + + do_test_pending(); +} diff --git a/devtools/server/tests/unit/test_protocol_unregister.js b/devtools/server/tests/unit/test_protocol_unregister.js new file mode 100644 index 000000000..5b32dd0a3 --- /dev/null +++ b/devtools/server/tests/unit/test_protocol_unregister.js @@ -0,0 +1,44 @@ +const {types} = require("devtools/shared/protocol"); + + +function run_test() +{ + types.addType("test", { + read: (v) => { return "successful read: " + v; }, + write: (v) => { return "successful write: " + v; } + }); + + // Verify the type registered correctly. + + let type = types.getType("test"); + let arrayType = types.getType("array:test"); + do_check_eq(type.read("foo"), "successful read: foo"); + do_check_eq(arrayType.read(["foo"])[0], "successful read: foo"); + + types.removeType("test"); + + do_check_eq(type.name, "DEFUNCT:test"); + try { + types.getType("test"); + do_check_true(false, "getType should fail"); + } catch (ex) { + do_check_eq(ex.toString(), "Error: Unknown type: test"); + } + + try { + type.read("foo"); + do_check_true(false, "type.read should have thrown an exception."); + } catch (ex) { + do_check_eq(ex.toString(), "Error: Using defunct type: test"); + } + + try { + arrayType.read(["foo"]); + do_check_true(false, "array:test.read should have thrown an exception."); + } catch (ex) { + do_check_eq(ex.toString(), "Error: Using defunct type: test"); + } + +} + + diff --git a/devtools/server/tests/unit/test_reattach-thread.js b/devtools/server/tests/unit/test_reattach-thread.js new file mode 100644 index 000000000..6d089e896 --- /dev/null +++ b/devtools/server/tests/unit/test_reattach-thread.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that reattaching to a previously detached thread works. + */ + +var gClient, gDebuggee, gThreadClient, gTabClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = testGlobal("test-reattach"); + DebuggerServer.addTestGlobal(gDebuggee); + + let transport = DebuggerServer.connectPipe(); + gClient = new DebuggerClient(transport); + gClient.connect().then(() => { + attachTestTab(gClient, "test-reattach", (aReply, aTabClient) => { + gTabClient = aTabClient; + test_attach(); + }); + }); + do_test_pending(); +} + +function test_attach() +{ + gTabClient.attachThread({}, (aResponse, aThreadClient) => { + do_check_eq(aThreadClient.state, "paused"); + gThreadClient = aThreadClient; + aThreadClient.resume(test_detach); + }); +} + +function test_detach() +{ + gThreadClient.detach(() => { + do_check_eq(gThreadClient.state, "detached"); + do_check_eq(gTabClient.thread, null); + test_reattach(); + }); +} + +function test_reattach() +{ + gTabClient.attachThread({}, (aResponse, aThreadClient) => { + do_check_neq(gThreadClient, aThreadClient); + do_check_eq(aThreadClient.state, "paused"); + do_check_eq(gTabClient.thread, aThreadClient); + aThreadClient.resume(cleanup); + }); +} + +function cleanup() +{ + gClient.close().then(do_test_finished); +} diff --git a/devtools/server/tests/unit/test_registerClient.js b/devtools/server/tests/unit/test_registerClient.js new file mode 100644 index 000000000..c018e4454 --- /dev/null +++ b/devtools/server/tests/unit/test_registerClient.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test the DebuggerClient.registerClient API + +var EventEmitter = require("devtools/shared/event-emitter"); + +var gClient; +var gActors; +var gTestClient; + +function TestActor(conn) { + this.conn = conn; +} +TestActor.prototype = { + actorPrefix: "test", + + start: function () { + this.conn.sendActorEvent(this.actorID, "foo", { + hello: "world" + }); + return {}; + } +}; +TestActor.prototype.requestTypes = { + "start": TestActor.prototype.start +}; + +function TestClient(client, form) { + this.client = client; + this.actor = form.test; + this.events = ["foo"]; + EventEmitter.decorate(this); + client.registerClient(this); + + this.detached = false; +} +TestClient.prototype = { + start: function () { + this.client.request({ + to: this.actor, + type: "start" + }); + }, + + detach: function (onDone) { + this.detached = true; + onDone(); + } +}; + +function run_test() +{ + DebuggerServer.addGlobalActor(TestActor); + + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + + add_test(init); + add_test(test_client_events); + add_test(close_client); + run_next_test(); +} + +function init() +{ + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect() + .then(() => gClient.listTabs()) + .then(aResponse => { + gActors = aResponse; + gTestClient = new TestClient(gClient, aResponse); + run_next_test(); + }); +} + +function test_client_events() +{ + // Test DebuggerClient.registerClient and DebuggerServerConnection.sendActorEvent + gTestClient.on("foo", function (type, data) { + do_check_eq(type, "foo"); + do_check_eq(data.hello, "world"); + run_next_test(); + }); + gTestClient.start(); +} + +function close_client() { + gClient.close().then(() => { + // Check that client.detach method is call on client destruction + do_check_true(gTestClient.detached); + run_next_test(); + }); +} + diff --git a/devtools/server/tests/unit/test_register_actor.js b/devtools/server/tests/unit/test_register_actor.js new file mode 100644 index 000000000..8f3a243eb --- /dev/null +++ b/devtools/server/tests/unit/test_register_actor.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const Profiler = Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler); + +function check_actors(expect) { + do_check_eq(expect, DebuggerServer.tabActorFactories.hasOwnProperty("registeredActor1")); + do_check_eq(expect, DebuggerServer.tabActorFactories.hasOwnProperty("registeredActor2")); + + do_check_eq(expect, DebuggerServer.globalActorFactories.hasOwnProperty("registeredActor2")); + do_check_eq(expect, DebuggerServer.globalActorFactories.hasOwnProperty("registeredActor1")); +} + +function run_test() +{ + // Allow incoming connections. + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + + add_test(test_deprecated_api); + add_test(test_lazy_api); + add_test(cleanup); + run_next_test(); +} + +function test_deprecated_api() { + // The xpcshell-test/ path maps to resource://test/ + DebuggerServer.registerModule("xpcshell-test/registertestactors-01"); + DebuggerServer.registerModule("xpcshell-test/registertestactors-02"); + + check_actors(true); + + check_except(() => { + DebuggerServer.registerModule("xpcshell-test/registertestactors-01"); + }); + check_except(() => { + DebuggerServer.registerModule("xpcshell-test/registertestactors-02"); + }); + + DebuggerServer.unregisterModule("xpcshell-test/registertestactors-01"); + DebuggerServer.unregisterModule("xpcshell-test/registertestactors-02"); + check_actors(false); + + DebuggerServer.registerModule("xpcshell-test/registertestactors-01"); + DebuggerServer.registerModule("xpcshell-test/registertestactors-02"); + check_actors(true); + + run_next_test(); +} + +// Bug 988237: Test the new lazy actor loading +function test_lazy_api() { + let isActorLoaded = false; + let isActorInstanciated = false; + function onActorEvent(subject, topic, data) { + if (data == "loaded") { + isActorLoaded = true; + } else if (data == "instantiated") { + isActorInstanciated = true; + } + } + Services.obs.addObserver(onActorEvent, "actor", false); + DebuggerServer.registerModule("xpcshell-test/registertestactors-03", { + prefix: "lazy", + constructor: "LazyActor", + type: { global: true, tab: true } + }); + // The actor is immediatly registered, but not loaded + do_check_true(DebuggerServer.tabActorFactories.hasOwnProperty("lazyActor")); + do_check_true(DebuggerServer.globalActorFactories.hasOwnProperty("lazyActor")); + do_check_false(isActorLoaded); + do_check_false(isActorInstanciated); + + let client = new DebuggerClient(DebuggerServer.connectPipe()); + client.connect().then(function onConnect() { + client.listTabs(onListTabs); + }); + function onListTabs(aResponse) { + // On listTabs, the actor is still not loaded, + // but we can see its name in the list of available actors + do_check_false(isActorLoaded); + do_check_false(isActorInstanciated); + do_check_true("lazyActor" in aResponse); + + let {LazyFront} = require("xpcshell-test/registertestactors-03"); + let front = LazyFront(client, aResponse); + front.hello().then(onRequest); + } + function onRequest(aResponse) { + do_check_eq(aResponse, "world"); + + // Finally, the actor is loaded on the first request being made to it + do_check_true(isActorLoaded); + do_check_true(isActorInstanciated); + + Services.obs.removeObserver(onActorEvent, "actor", false); + client.close().then(() => run_next_test()); + } +} + +function cleanup() { + DebuggerServer.destroy(); + + // Check that all actors are unregistered on server destruction + check_actors(false); + do_check_false(DebuggerServer.tabActorFactories.hasOwnProperty("lazyActor")); + do_check_false(DebuggerServer.globalActorFactories.hasOwnProperty("lazyActor")); + + run_next_test(); +} + diff --git a/devtools/server/tests/unit/test_requestTypes.js b/devtools/server/tests/unit/test_requestTypes.js new file mode 100644 index 000000000..694e276bc --- /dev/null +++ b/devtools/server/tests/unit/test_requestTypes.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { RootActor } = require("devtools/server/actors/root"); + +function test_requestTypes_request(aClient, anActor) +{ + aClient.request({ to: "root", type: "requestTypes" }, function (aResponse) { + var expectedRequestTypes = Object.keys(RootActor. + prototype. + requestTypes); + + do_check_true(Array.isArray(aResponse.requestTypes)); + do_check_eq(JSON.stringify(aResponse.requestTypes), + JSON.stringify(expectedRequestTypes)); + + aClient.close().then(() => { + do_test_finished(); + }); + }); +} + +function run_test() +{ + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + + var client = new DebuggerClient(DebuggerServer.connectPipe()); + client.connect().then(function () { + test_requestTypes_request(client); + }); + + do_test_pending(); +} diff --git a/devtools/server/tests/unit/test_safe-getter.js b/devtools/server/tests/unit/test_safe-getter.js new file mode 100644 index 000000000..08949154d --- /dev/null +++ b/devtools/server/tests/unit/test_safe-getter.js @@ -0,0 +1,25 @@ +function run_test() { + Components.utils.import("resource://gre/modules/jsdebugger.jsm"); + addDebuggerToGlobal(this); + var g = testGlobal("test"); + var dbg = new Debugger(); + var gw = dbg.addDebuggee(g); + + g.eval(` + // This is not a CCW. + Object.defineProperty(this, "bar", { + get: function() { return "bar"; }, + configurable: true, + enumerable: true + }); + + Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + + // This is a CCW. + XPCOMUtils.defineLazyGetter(this, "foo", function() { return "foo"; }); + `); + + // Neither scripted getter should be considered safe. + assert(!DevToolsUtils.hasSafeGetter(gw.getOwnPropertyDescriptor("bar"))); + assert(!DevToolsUtils.hasSafeGetter(gw.getOwnPropertyDescriptor("foo"))); +} diff --git a/devtools/server/tests/unit/test_setBreakpoint-on-column-in-gcd-script.js b/devtools/server/tests/unit/test_setBreakpoint-on-column-in-gcd-script.js new file mode 100644 index 000000000..a9d82e434 --- /dev/null +++ b/devtools/server/tests/unit/test_setBreakpoint-on-column-in-gcd-script.js @@ -0,0 +1,58 @@ +"use strict"; + +var SOURCE_URL = getFileUrl("setBreakpoint-on-column-in-gcd-script.js"); + +function run_test() { + return Task.spawn(function* () { + do_test_pending(); + + let global = testGlobal("test"); + loadSubScript(SOURCE_URL, global); + Cu.forceGC(); Cu.forceGC(); Cu.forceGC(); + + DebuggerServer.registerModule("xpcshell-test/testactors"); + DebuggerServer.init(() => true); + DebuggerServer.addTestGlobal(global); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + yield connect(client); + + let { tabs } = yield listTabs(client); + let tab = findTab(tabs, "test"); + let [, tabClient] = yield attachTab(client, tab); + let [, threadClient] = yield attachThread(tabClient); + yield resume(threadClient); + + let { sources } = yield getSources(threadClient); + let source = findSource(sources, SOURCE_URL); + let sourceClient = threadClient.source(source); + + let location = { line: 6, column: 17 }; + let [packet, breakpointClient] = yield setBreakpoint(sourceClient, location); + do_check_true(packet.isPending); + do_check_false("actualLocation" in packet); + + packet = yield executeOnNextTickAndWaitForPause(function () { + reload(tabClient).then(function () { + loadSubScript(SOURCE_URL, global); + }); + }, client); + do_check_eq(packet.type, "paused"); + let why = packet.why; + do_check_eq(why.type, "breakpoint"); + do_check_eq(why.actors.length, 1); + do_check_eq(why.actors[0], breakpointClient.actor); + let frame = packet.frame; + let where = frame.where; + do_check_eq(where.source.actor, source.actor); + do_check_eq(where.line, location.line); + do_check_eq(where.column, location.column); + let variables = frame.environment.bindings.variables; + do_check_eq(variables.a.value, 1); + do_check_eq(variables.b.value.type, "undefined"); + do_check_eq(variables.c.value.type, "undefined"); + yield resume(threadClient); + + yield close(client); + do_test_finished(); + }); +} diff --git a/devtools/server/tests/unit/test_setBreakpoint-on-column-with-no-offsets-at-end-of-line.js b/devtools/server/tests/unit/test_setBreakpoint-on-column-with-no-offsets-at-end-of-line.js new file mode 100644 index 000000000..6185cf589 --- /dev/null +++ b/devtools/server/tests/unit/test_setBreakpoint-on-column-with-no-offsets-at-end-of-line.js @@ -0,0 +1,39 @@ +"use strict"; + +var SOURCE_URL = getFileUrl("setBreakpoint-on-column-with-no-offsets-at-end-of-line.js"); + +function run_test() { + return Task.spawn(function* () { + do_test_pending(); + + DebuggerServer.registerModule("xpcshell-test/testactors"); + DebuggerServer.init(() => true); + + let global = createTestGlobal("test"); + DebuggerServer.addTestGlobal(global); + + let client = new DebuggerClient(DebuggerServer.connectPipe()); + yield connect(client); + + let { tabs } = yield listTabs(client); + let tab = findTab(tabs, "test"); + let [, tabClient] = yield attachTab(client, tab); + let [, threadClient] = yield attachThread(tabClient); + yield resume(threadClient); + + let promise = waitForNewSource(threadClient, SOURCE_URL); + loadSubScript(SOURCE_URL, global); + let { source } = yield promise; + let sourceClient = threadClient.source(source); + + let location = { line: 4, column: 23 }; + let [packet, breakpointClient] = yield setBreakpoint(sourceClient, location); + do_check_true(packet.isPending); + do_check_false("actualLocation" in packet); + + Cu.evalInSandbox("f()", global); + yield close(client); + + do_test_finished(); + }); +} diff --git a/devtools/server/tests/unit/test_setBreakpoint-on-column.js b/devtools/server/tests/unit/test_setBreakpoint-on-column.js new file mode 100644 index 000000000..bee9fe004 --- /dev/null +++ b/devtools/server/tests/unit/test_setBreakpoint-on-column.js @@ -0,0 +1,57 @@ +"use strict"; + +var SOURCE_URL = getFileUrl("setBreakpoint-on-column.js"); + +function run_test() { + return Task.spawn(function* () { + do_test_pending(); + + DebuggerServer.registerModule("xpcshell-test/testactors"); + DebuggerServer.init(() => true); + + let global = createTestGlobal("test"); + DebuggerServer.addTestGlobal(global); + + let client = new DebuggerClient(DebuggerServer.connectPipe()); + yield connect(client); + + let { tabs } = yield listTabs(client); + let tab = findTab(tabs, "test"); + let [, tabClient] = yield attachTab(client, tab); + let [, threadClient] = yield attachThread(tabClient); + yield resume(threadClient); + + let promise = waitForNewSource(threadClient, SOURCE_URL); + loadSubScript(SOURCE_URL, global); + let { source } = yield promise; + let sourceClient = threadClient.source(source); + + let location = { line: 4, column: 17 }; + let [packet, breakpointClient] = yield setBreakpoint(sourceClient, location); + do_check_false(packet.isPending); + do_check_false("actualLocation" in packet); + + packet = yield executeOnNextTickAndWaitForPause(function () { + Cu.evalInSandbox("f()", global); + }, client); + do_check_eq(packet.type, "paused"); + let why = packet.why; + do_check_eq(why.type, "breakpoint"); + do_check_eq(why.actors.length, 1); + do_check_eq(why.actors[0], breakpointClient.actor); + let frame = packet.frame; + let where = frame.where; + do_check_eq(where.source.actor, source.actor); + do_check_eq(where.line, location.line); + do_check_eq(where.column, location.column); + let variables = frame.environment.bindings.variables; + do_check_eq(variables.a.value, 1); + do_check_eq(variables.b.value.type, "undefined"); + do_check_eq(variables.c.value.type, "undefined"); + + yield resume(threadClient); + yield close(client); + + do_test_finished(); + }); +} diff --git a/devtools/server/tests/unit/test_setBreakpoint-on-line-in-gcd-script.js b/devtools/server/tests/unit/test_setBreakpoint-on-line-in-gcd-script.js new file mode 100644 index 000000000..8479c797e --- /dev/null +++ b/devtools/server/tests/unit/test_setBreakpoint-on-line-in-gcd-script.js @@ -0,0 +1,57 @@ +"use strict"; + +var SOURCE_URL = getFileUrl("setBreakpoint-on-line-in-gcd-script.js"); + +function run_test() { + return Task.spawn(function* () { + do_test_pending(); + + let global = createTestGlobal("test"); + loadSubScript(SOURCE_URL, global); + Cu.forceGC(); Cu.forceGC(); Cu.forceGC(); + + DebuggerServer.registerModule("xpcshell-test/testactors"); + DebuggerServer.init(() => true); + DebuggerServer.addTestGlobal(global); + let client = new DebuggerClient(DebuggerServer.connectPipe()); + yield connect(client); + + let { tabs } = yield listTabs(client); + let tab = findTab(tabs, "test"); + let [, tabClient] = yield attachTab(client, tab); + let [, threadClient] = yield attachThread(tabClient); + yield resume(threadClient); + + let { sources } = yield getSources(threadClient); + let source = findSource(sources, SOURCE_URL); + let sourceClient = threadClient.source(source); + + let location = { line: 7 }; + let [packet, breakpointClient] = yield setBreakpoint(sourceClient, location); + do_check_true(packet.isPending); + do_check_false("actualLocation" in packet); + + packet = yield executeOnNextTickAndWaitForPause(function () { + reload(tabClient).then(function () { + loadSubScript(SOURCE_URL, global); + }); + }, client); + do_check_eq(packet.type, "paused"); + let why = packet.why; + do_check_eq(why.type, "breakpoint"); + do_check_eq(why.actors.length, 1); + do_check_eq(why.actors[0], breakpointClient.actor); + let frame = packet.frame; + let where = frame.where; + do_check_eq(where.source.actor, source.actor); + do_check_eq(where.line, location.line); + let variables = frame.environment.bindings.variables; + do_check_eq(variables.a.value, 1); + do_check_eq(variables.b.value.type, "undefined"); + do_check_eq(variables.c.value.type, "undefined"); + yield resume(threadClient); + + yield close(client); + do_test_finished(); + }); +} diff --git a/devtools/server/tests/unit/test_setBreakpoint-on-line-with-multiple-offsets.js b/devtools/server/tests/unit/test_setBreakpoint-on-line-with-multiple-offsets.js new file mode 100644 index 000000000..2f5c1b9aa --- /dev/null +++ b/devtools/server/tests/unit/test_setBreakpoint-on-line-with-multiple-offsets.js @@ -0,0 +1,70 @@ +"use strict"; + +var SOURCE_URL = getFileUrl("setBreakpoint-on-line-with-multiple-offsets.js"); + +function run_test() { + return Task.spawn(function* () { + do_test_pending(); + + DebuggerServer.registerModule("xpcshell-test/testactors"); + DebuggerServer.init(() => true); + + let global = createTestGlobal("test"); + DebuggerServer.addTestGlobal(global); + + let client = new DebuggerClient(DebuggerServer.connectPipe()); + yield connect(client); + + let { tabs } = yield listTabs(client); + let tab = findTab(tabs, "test"); + let [, tabClient] = yield attachTab(client, tab); + + let [, threadClient] = yield attachThread(tabClient); + yield resume(threadClient); + + let promise = waitForNewSource(threadClient, SOURCE_URL); + loadSubScript(SOURCE_URL, global); + let { source } = yield promise; + let sourceClient = threadClient.source(source); + + let location = { line: 4 }; + let [packet, breakpointClient] = yield setBreakpoint(sourceClient, location); + do_check_false(packet.isPending); + do_check_false("actualLocation" in packet); + + packet = yield executeOnNextTickAndWaitForPause(function () { + Cu.evalInSandbox("f()", global); + }, client); + do_check_eq(packet.type, "paused"); + let why = packet.why; + do_check_eq(why.type, "breakpoint"); + do_check_eq(why.actors.length, 1); + do_check_eq(why.actors[0], breakpointClient.actor); + let frame = packet.frame; + let where = frame.where; + do_check_eq(where.source.actor, source.actor); + do_check_eq(where.line, location.line); + let variables = frame.environment.bindings.variables; + do_check_eq(variables.i.value.type, "undefined"); + + packet = yield executeOnNextTickAndWaitForPause(function () { + resume(threadClient); + }, client); + do_check_eq(packet.type, "paused"); + why = packet.why; + do_check_eq(why.type, "breakpoint"); + do_check_eq(why.actors.length, 1); + do_check_eq(why.actors[0], breakpointClient.actor); + frame = packet.frame; + where = frame.where; + do_check_eq(where.source.actor, source.actor); + do_check_eq(where.line, location.line); + variables = frame.environment.bindings.variables; + do_check_eq(variables.i.value, 0); + + yield resume(threadClient); + yield close(client); + + do_test_finished(); + }); +} diff --git a/devtools/server/tests/unit/test_setBreakpoint-on-line-with-multiple-statements.js b/devtools/server/tests/unit/test_setBreakpoint-on-line-with-multiple-statements.js new file mode 100644 index 000000000..104152441 --- /dev/null +++ b/devtools/server/tests/unit/test_setBreakpoint-on-line-with-multiple-statements.js @@ -0,0 +1,57 @@ +"use strict"; + +var SOURCE_URL = getFileUrl("setBreakpoint-on-line-with-multiple-statements.js"); + +function run_test() { + return Task.spawn(function* () { + do_test_pending(); + + DebuggerServer.registerModule("xpcshell-test/testactors"); + DebuggerServer.init(() => true); + + let global = createTestGlobal("test"); + DebuggerServer.addTestGlobal(global); + + let client = new DebuggerClient(DebuggerServer.connectPipe()); + yield connect(client); + + let { tabs } = yield listTabs(client); + let tab = findTab(tabs, "test"); + let [, tabClient] = yield attachTab(client, tab); + + let [, threadClient] = yield attachThread(tabClient); + yield resume(threadClient); + + let promise = waitForNewSource(threadClient, SOURCE_URL); + loadSubScript(SOURCE_URL, global); + let { source } = yield promise; + let sourceClient = threadClient.source(source); + + let location = { line: 4 }; + let [packet, breakpointClient] = yield setBreakpoint(sourceClient, location); + do_check_false(packet.isPending); + do_check_false("actualLocation" in packet); + + packet = yield executeOnNextTickAndWaitForPause(function () { + Cu.evalInSandbox("f()", global); + }, client); + do_check_eq(packet.type, "paused"); + let why = packet.why; + do_check_eq(why.type, "breakpoint"); + do_check_eq(why.actors.length, 1); + do_check_eq(why.actors[0], breakpointClient.actor); + let frame = packet.frame; + let where = frame.where; + do_check_eq(where.source.actor, source.actor); + do_check_eq(where.line, location.line); + let variables = frame.environment.bindings.variables; + do_check_eq(variables.a.value.type, "undefined"); + do_check_eq(variables.b.value.type, "undefined"); + do_check_eq(variables.c.value.type, "undefined"); + + yield resume(threadClient); + yield close(client); + + do_test_finished(); + }); +} diff --git a/devtools/server/tests/unit/test_setBreakpoint-on-line-with-no-offsets-in-gcd-script.js b/devtools/server/tests/unit/test_setBreakpoint-on-line-with-no-offsets-in-gcd-script.js new file mode 100644 index 000000000..2e841fe19 --- /dev/null +++ b/devtools/server/tests/unit/test_setBreakpoint-on-line-with-no-offsets-in-gcd-script.js @@ -0,0 +1,58 @@ +"use strict"; + +var SOURCE_URL = getFileUrl("setBreakpoint-on-line-with-no-offsets-in-gcd-script.js"); + +function run_test() { + return Task.spawn(function* () { + do_test_pending(); + + let global = createTestGlobal("test"); + loadSubScript(SOURCE_URL, global); + Cu.forceGC(); Cu.forceGC(); Cu.forceGC(); + + DebuggerServer.registerModule("xpcshell-test/testactors"); + DebuggerServer.init(() => true); + DebuggerServer.addTestGlobal(global); + + let client = new DebuggerClient(DebuggerServer.connectPipe()); + yield connect(client); + + let { tabs } = yield listTabs(client); + let tab = findTab(tabs, "test"); + let [, tabClient] = yield attachTab(client, tab); + let [, threadClient] = yield attachThread(tabClient); + yield resume(threadClient); + + let { sources } = yield getSources(threadClient); + let source = findSource(sources, SOURCE_URL); + let sourceClient = threadClient.source(source); + + let location = { line: 7 }; + let [packet, breakpointClient] = yield setBreakpoint(sourceClient, location); + do_check_true(packet.isPending); + do_check_false("actualLocation" in packet); + + packet = yield executeOnNextTickAndWaitForPause(function () { + reload(tabClient).then(function () { + loadSubScript(SOURCE_URL, global); + }); + }, client); + do_check_eq(packet.type, "paused"); + let why = packet.why; + do_check_eq(why.type, "breakpoint"); + do_check_eq(why.actors.length, 1); + do_check_eq(why.actors[0], breakpointClient.actor); + let frame = packet.frame; + let where = frame.where; + do_check_eq(where.source.actor, source.actor); + do_check_eq(where.line, 8); + let variables = frame.environment.bindings.variables; + do_check_eq(variables.a.value, 1); + do_check_eq(variables.c.value.type, "undefined"); + + yield resume(threadClient); + yield close(client); + + do_test_finished(); + }); +} diff --git a/devtools/server/tests/unit/test_setBreakpoint-on-line-with-no-offsets.js b/devtools/server/tests/unit/test_setBreakpoint-on-line-with-no-offsets.js new file mode 100644 index 000000000..5959b23ef --- /dev/null +++ b/devtools/server/tests/unit/test_setBreakpoint-on-line-with-no-offsets.js @@ -0,0 +1,57 @@ +"use strict"; + +var SOURCE_URL = getFileUrl("setBreakpoint-on-line-with-no-offsets.js"); + +function run_test() { + return Task.spawn(function* () { + do_test_pending(); + + DebuggerServer.registerModule("xpcshell-test/testactors"); + DebuggerServer.init(() => true); + + let global = createTestGlobal("test"); + DebuggerServer.addTestGlobal(global); + + let client = new DebuggerClient(DebuggerServer.connectPipe()); + yield connect(client); + + let { tabs } = yield listTabs(client); + let tab = findTab(tabs, "test"); + let [, tabClient] = yield attachTab(client, tab); + let [, threadClient] = yield attachThread(tabClient); + yield resume(threadClient); + + let promise = waitForNewSource(threadClient, SOURCE_URL); + loadSubScript(SOURCE_URL, global); + let { source } = yield promise; + let sourceClient = threadClient.source(source); + + let location = { line: 5 }; + let [packet, breakpointClient] = yield setBreakpoint(sourceClient, location); + do_check_false(packet.isPending); + do_check_true("actualLocation" in packet); + let actualLocation = packet.actualLocation; + do_check_eq(actualLocation.line, 6); + + packet = yield executeOnNextTickAndWaitForPause(function () { + Cu.evalInSandbox("f()", global); + }, client); + do_check_eq(packet.type, "paused"); + let why = packet.why; + do_check_eq(why.type, "breakpoint"); + do_check_eq(why.actors.length, 1); + do_check_eq(why.actors[0], breakpointClient.actor); + let frame = packet.frame; + let where = frame.where; + do_check_eq(where.source.actor, source.actor); + do_check_eq(where.line, actualLocation.line); + let variables = frame.environment.bindings.variables; + do_check_eq(variables.a.value, 1); + do_check_eq(variables.c.value.type, "undefined"); + + yield resume(threadClient); + yield close(client); + + do_test_finished(); + }); +} diff --git a/devtools/server/tests/unit/test_setBreakpoint-on-line.js b/devtools/server/tests/unit/test_setBreakpoint-on-line.js new file mode 100644 index 000000000..1dab6a633 --- /dev/null +++ b/devtools/server/tests/unit/test_setBreakpoint-on-line.js @@ -0,0 +1,57 @@ +"use strict"; + +var SOURCE_URL = getFileUrl("setBreakpoint-on-line.js"); + +function run_test() { + return Task.spawn(function* () { + do_test_pending(); + + DebuggerServer.registerModule("xpcshell-test/testactors"); + DebuggerServer.init(() => true); + + let global = createTestGlobal("test"); + DebuggerServer.addTestGlobal(global); + + let client = new DebuggerClient(DebuggerServer.connectPipe()); + yield connect(client); + + let { tabs } = yield listTabs(client); + let tab = findTab(tabs, "test"); + let [, tabClient] = yield attachTab(client, tab); + + let [, threadClient] = yield attachThread(tabClient); + yield resume(threadClient); + + let promise = waitForNewSource(threadClient, SOURCE_URL); + loadSubScript(SOURCE_URL, global); + let { source } = yield promise; + let sourceClient = threadClient.source(source); + + let location = { line: 5 }; + let [packet, breakpointClient] = yield setBreakpoint(sourceClient, location); + do_check_false(packet.isPending); + do_check_false("actualLocation" in packet); + + packet = yield executeOnNextTickAndWaitForPause(function () { + Cu.evalInSandbox("f()", global); + }, client); + do_check_eq(packet.type, "paused"); + let why = packet.why; + do_check_eq(why.type, "breakpoint"); + do_check_eq(why.actors.length, 1); + do_check_eq(why.actors[0], breakpointClient.actor); + let frame = packet.frame; + let where = frame.where; + do_check_eq(where.source.actor, source.actor); + do_check_eq(where.line, location.line); + let variables = frame.environment.bindings.variables; + do_check_eq(variables.a.value, 1); + do_check_eq(variables.b.value.type, "undefined"); + do_check_eq(variables.c.value.type, "undefined"); + + yield resume(threadClient); + yield close(client); + + do_test_finished(); + }); +} diff --git a/devtools/server/tests/unit/test_source-01.js b/devtools/server/tests/unit/test_source-01.js new file mode 100644 index 000000000..3401cc660 --- /dev/null +++ b/devtools/server/tests/unit/test_source-01.js @@ -0,0 +1,78 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var gDebuggee; +var gClient; +var gThreadClient; + +// This test ensures that we can create SourceActors and SourceClients properly, +// and that they can communicate over the protocol to fetch the source text for +// a given script. + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-grips"); + Cu.evalInSandbox( + "" + function stopMe(arg1) { + debugger; + }, + gDebuggee, + "1.8", + getFileUrl("test_source-01.js") + ); + + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_source(); + }); + }); + do_test_pending(); +} + +const SOURCE_URL = "http://example.com/foobar.js"; +const SOURCE_CONTENT = "stopMe()"; + +function test_source() +{ + DebuggerServer.LONG_STRING_LENGTH = 200; + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.getSources(function (aResponse) { + do_check_true(!!aResponse); + do_check_true(!!aResponse.sources); + + let source = aResponse.sources.filter(function (s) { + return s.url === SOURCE_URL; + })[0]; + + do_check_true(!!source); + + let sourceClient = gThreadClient.source(source); + sourceClient.source(function (aResponse) { + do_check_true(!!aResponse); + do_check_true(!aResponse.error); + do_check_true(!!aResponse.contentType); + do_check_true(aResponse.contentType.includes("javascript")); + + do_check_true(!!aResponse.source); + do_check_eq(SOURCE_CONTENT, + aResponse.source); + + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + }); + + Cu.evalInSandbox( + SOURCE_CONTENT, + gDebuggee, + "1.8", + SOURCE_URL + ); +} diff --git a/devtools/server/tests/unit/test_sourcemaps-01.js b/devtools/server/tests/unit/test_sourcemaps-01.js new file mode 100644 index 000000000..4d92bf9cc --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-01.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check basic source map integration with the "newSource" packet in the RDP. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +const {SourceNode} = require("source-map"); + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-source-map"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-source-map", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_source_map(); + }); + }); + do_test_pending(); +} + +function test_simple_source_map() +{ + // Because we are source mapping, we should be notified of a.js, b.js, and + // c.js as sources, and shouldn't receive abc.js or test_sourcemaps-01.js. + let expectedSources = new Set(["http://example.com/www/js/a.js", + "http://example.com/www/js/b.js", + "http://example.com/www/js/c.js"]); + + gThreadClient.addListener("newSource", function _onNewSource(aEvent, aPacket) { + do_check_eq(aEvent, "newSource"); + do_check_eq(aPacket.type, "newSource"); + do_check_true(!!aPacket.source); + + do_check_true(expectedSources.has(aPacket.source.url), + "The source url should be one of our original sources."); + expectedSources.delete(aPacket.source.url); + + if (expectedSources.size === 0) { + gClient.removeListener("newSource", _onNewSource); + finishClient(gClient); + } + }); + + let { code, map } = (new SourceNode(null, null, null, [ + new SourceNode(1, 0, "a.js", "function a() { return 'a'; }\n"), + new SourceNode(1, 0, "b.js", "function b() { return 'b'; }\n"), + new SourceNode(1, 0, "c.js", "function c() { return 'c'; }\n"), + ])).toStringWithSourceMap({ + file: "abc.js", + sourceRoot: "http://example.com/www/js/" + }); + + code += "//# sourceMappingURL=data:text/json;base64," + btoa(map.toString()); + + Components.utils.evalInSandbox(code, gDebuggee, "1.8", + "http://example.com/www/js/abc.js", 1); +} diff --git a/devtools/server/tests/unit/test_sourcemaps-02.js b/devtools/server/tests/unit/test_sourcemaps-02.js new file mode 100644 index 000000000..2a343afaa --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-02.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check basic source map integration with the "sources" packet in the RDP. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +const {SourceNode} = require("source-map"); + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-source-map"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-source-map", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_source_map(); + }); + }); + do_test_pending(); +} + +function test_simple_source_map() +{ + // Because we are source mapping, we should be notified of a.js, b.js, and + // c.js as sources, and shouldn"t receive abc.js or test_sourcemaps-01.js. + let expectedSources = new Set(["http://example.com/www/js/a.js", + "http://example.com/www/js/b.js", + "http://example.com/www/js/c.js"]); + + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.getSources(function (aResponse) { + do_check_true(!aResponse.error, "Should not get an error"); + + for (let s of aResponse.sources) { + do_check_neq(s.url, "http://example.com/www/js/abc.js", + "Shouldn't get the generated source's url."); + expectedSources.delete(s.url); + } + + do_check_eq(expectedSources.size, 0, + "Should have found all the expected sources sources by now."); + + finishClient(gClient); + }); + }); + + let { code, map } = (new SourceNode(null, null, null, [ + new SourceNode(1, 0, "a.js", "function a() { return 'a'; }\n"), + new SourceNode(1, 0, "b.js", "function b() { return 'b'; }\n"), + new SourceNode(1, 0, "c.js", "function c() { return 'c'; }\n"), + new SourceNode(1, 0, "d.js", "debugger;\n") + ])).toStringWithSourceMap({ + file: "abc.js", + sourceRoot: "http://example.com/www/js/" + }); + + code += "//# sourceMappingURL=data:text/json;base64," + btoa(map.toString()); + + Components.utils.evalInSandbox(code, gDebuggee, "1.8", + "http://example.com/www/js/abc.js", 1); +} diff --git a/devtools/server/tests/unit/test_sourcemaps-03.js b/devtools/server/tests/unit/test_sourcemaps-03.js new file mode 100644 index 000000000..2fbd99aad --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-03.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check setting breakpoints in source mapped sources. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +const {SourceNode} = require("source-map"); + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-source-map"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-source-map", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_source_map(); + }); + }); + do_test_pending(); +} + +function testBreakpointMapping(aName, aCallback) +{ + Task.spawn(function* () { + let response = yield waitForPause(gThreadClient); + do_check_eq(response.why.type, "debuggerStatement"); + + const source = yield getSource(gThreadClient, "http://example.com/www/js/" + aName + ".js"); + response = yield setBreakpoint(source, { + // Setting the breakpoint on an empty line so that it is pushed down one + // line and we can check the source mapped actualLocation later. + line: 3 + }); + + // Should not slide breakpoints for sourcemapped sources + do_check_true(!response.actualLocation); + + yield setBreakpoint(source, { line: 4 }); + + // The eval will cause us to resume, then we get an unsolicited pause + // because of our breakpoint, we resume again to finish the eval, and + // finally receive our last pause which has the result of the client + // evaluation. + response = yield gThreadClient.eval(null, aName + "()"); + do_check_eq(response.type, "resumed"); + + response = yield waitForPause(gThreadClient); + do_check_eq(response.why.type, "breakpoint"); + // Assert that we paused because of the breakpoint at the correct + // location in the code by testing that the value of `ret` is still + // undefined. + do_check_eq(response.frame.environment.bindings.variables.ret.value.type, + "undefined"); + + response = yield resume(gThreadClient); + + response = yield waitForPause(gThreadClient); + do_check_eq(response.why.type, "clientEvaluated"); + do_check_eq(response.why.frameFinished.return, aName); + + response = yield resume(gThreadClient); + + aCallback(); + }); + + gDebuggee.eval("(" + function () { + debugger; + } + "());"); +} + +function test_simple_source_map() +{ + let expectedSources = new Set([ + "http://example.com/www/js/a.js", + "http://example.com/www/js/b.js", + "http://example.com/www/js/c.js" + ]); + + gThreadClient.addListener("newSource", function _onNewSource(aEvent, aPacket) { + expectedSources.delete(aPacket.source.url); + if (expectedSources.size > 0) { + return; + } + gThreadClient.removeListener("newSource", _onNewSource); + + testBreakpointMapping("a", function () { + testBreakpointMapping("b", function () { + testBreakpointMapping("c", function () { + finishClient(gClient); + }); + }); + }); + }); + + let a = new SourceNode(null, null, null, [ + new SourceNode(1, 0, "a.js", "function a() {\n"), + new SourceNode(2, 0, "a.js", " var ret;\n"), + new SourceNode(3, 0, "a.js", " // Empty line\n"), + new SourceNode(4, 0, "a.js", " ret = 'a';\n"), + new SourceNode(5, 0, "a.js", " return ret;\n"), + new SourceNode(6, 0, "a.js", "}\n") + ]); + let b = new SourceNode(null, null, null, [ + new SourceNode(1, 0, "b.js", "function b() {\n"), + new SourceNode(2, 0, "b.js", " var ret;\n"), + new SourceNode(3, 0, "b.js", " // Empty line\n"), + new SourceNode(4, 0, "b.js", " ret = 'b';\n"), + new SourceNode(5, 0, "b.js", " return ret;\n"), + new SourceNode(6, 0, "b.js", "}\n") + ]); + let c = new SourceNode(null, null, null, [ + new SourceNode(1, 0, "c.js", "function c() {\n"), + new SourceNode(2, 0, "c.js", " var ret;\n"), + new SourceNode(3, 0, "c.js", " // Empty line\n"), + new SourceNode(4, 0, "c.js", " ret = 'c';\n"), + new SourceNode(5, 0, "c.js", " return ret;\n"), + new SourceNode(6, 0, "c.js", "}\n") + ]); + + let { code, map } = (new SourceNode(null, null, null, [ + a, b, c + ])).toStringWithSourceMap({ + file: "http://example.com/www/js/abc.js", + sourceRoot: "http://example.com/www/js/" + }); + + code += "//# sourceMappingURL=data:text/json;base64," + btoa(map.toString()); + + Components.utils.evalInSandbox(code, gDebuggee, "1.8", + "http://example.com/www/js/abc.js", 1); +} diff --git a/devtools/server/tests/unit/test_sourcemaps-04.js b/devtools/server/tests/unit/test_sourcemaps-04.js new file mode 100644 index 000000000..5fecb44c8 --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-04.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that absolute source map urls work. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-source-map"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-source-map", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_absolute_source_map(); + }); + }); + do_test_pending(); +} + +function test_absolute_source_map() +{ + gThreadClient.addOneTimeListener("newSource", function _onNewSource(aEvent, aPacket) { + do_check_eq(aEvent, "newSource"); + do_check_eq(aPacket.type, "newSource"); + do_check_true(!!aPacket.source); + + do_check_true(aPacket.source.url.indexOf("sourcemapped.coffee") !== -1, + "The new source should be a coffee file."); + do_check_eq(aPacket.source.url.indexOf("sourcemapped.js"), -1, + "The new source should not be a js file."); + + finishClient(gClient); + }); + + code = readFile("sourcemapped.js") + + "\n//# sourceMappingURL=" + getFileUrl("source-map-data/sourcemapped.map"); + + Components.utils.evalInSandbox(code, gDebuggee, "1.8", + getFileUrl("sourcemapped.js"), 1); +} diff --git a/devtools/server/tests/unit/test_sourcemaps-05.js b/devtools/server/tests/unit/test_sourcemaps-05.js new file mode 100644 index 000000000..edc9178db --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-05.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that relative source map urls work. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-source-map"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-source-map", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_relative_source_map(); + }); + }); + do_test_pending(); +} + +function test_relative_source_map() +{ + gThreadClient.addOneTimeListener("newSource", function _onNewSource(aEvent, aPacket) { + do_check_eq(aEvent, "newSource"); + do_check_eq(aPacket.type, "newSource"); + do_check_true(!!aPacket.source); + + do_check_true(aPacket.source.url.indexOf("sourcemapped.coffee") !== -1, + "The new source should be a coffee file."); + do_check_eq(aPacket.source.url.indexOf("sourcemapped.js"), -1, + "The new source should not be a js file."); + + finishClient(gClient); + }); + + code = readFile("sourcemapped.js") + + "\n//# sourceMappingURL=source-map-data/sourcemapped.map"; + + Components.utils.evalInSandbox(code, gDebuggee, "1.8", + getFileUrl("sourcemapped.js"), 1); +} diff --git a/devtools/server/tests/unit/test_sourcemaps-06.js b/devtools/server/tests/unit/test_sourcemaps-06.js new file mode 100644 index 000000000..41b518753 --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-06.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that we can load sources whose content is embedded in the + * "sourcesContent" field of a source map. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +const {SourceNode} = require("source-map"); + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-source-map"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-source-map", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_source_content(); + }); + }); + do_test_pending(); +} + +function test_source_content() +{ + let numNewSources = 0; + + gThreadClient.addListener("newSource", function _onNewSource(aEvent, aPacket) { + if (++numNewSources !== 3) { + return; + } + gThreadClient.removeListener("newSource", _onNewSource); + + gThreadClient.getSources(function (aResponse) { + do_check_true(!aResponse.error, "Should not get an error"); + + testContents(aResponse.sources, 0, (timesCalled) => { + do_check_eq(timesCalled, 3); + finishClient(gClient); + }); + }); + }); + + let node = new SourceNode(null, null, null, [ + new SourceNode(1, 0, "a.js", "function a() { return 'a'; }\n"), + new SourceNode(1, 0, "b.js", "function b() { return 'b'; }\n"), + new SourceNode(1, 0, "c.js", "function c() { return 'c'; }\n"), + ]); + + node.setSourceContent("a.js", "content for a.js"); + node.setSourceContent("b.js", "content for b.js"); + node.setSourceContent("c.js", "content for c.js"); + + let { code, map } = node.toStringWithSourceMap({ + file: "abc.js" + }); + + code += "//# sourceMappingURL=data:text/json;base64," + btoa(map.toString()); + + Components.utils.evalInSandbox(code, gDebuggee, "1.8", + "http://example.com/www/js/abc.js", 1); +} + +function testContents(sources, timesCalled, callback) { + if (sources.length === 0) { + callback(timesCalled); + return; + } + + + let source = sources[0]; + let sourceClient = gThreadClient.source(sources[0]); + + if (sourceClient.url) { + sourceClient.source((aResponse) => { + do_check_true(!aResponse.error, + "Should not get an error loading the source from sourcesContent"); + + let expectedContent = "content for " + source.url; + do_check_eq(aResponse.source, expectedContent, + "Should have the expected source content"); + + testContents(sources.slice(1), timesCalled + 1, callback); + }); + } + else { + testContents(sources.slice(1), timesCalled, callback); + } +} diff --git a/devtools/server/tests/unit/test_sourcemaps-07.js b/devtools/server/tests/unit/test_sourcemaps-07.js new file mode 100644 index 000000000..b8a9462c0 --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-07.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we don't permanently cache sources from source maps. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +const {SourceNode} = require("source-map"); + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-source-map"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-source-map", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_cached_original_sources(); + }); + }); + do_test_pending(); +} + +function test_cached_original_sources() +{ + writeFile("temp.js", "initial content"); + + gThreadClient.addOneTimeListener("newSource", onNewSource); + + let node = new SourceNode(1, 0, + getFileUrl("temp.js"), + "function funcFromTemp() {}\n"); + let { code, map } = node.toStringWithSourceMap({ + file: "abc.js" + }); + code += "//# sourceMappingURL=data:text/json;base64," + btoa(map.toString()); + + + Components.utils.evalInSandbox(code, gDebuggee, "1.8", + "http://example.com/www/js/abc.js", 1); +} + +function onNewSource(aEvent, aPacket) { + let sourceClient = gThreadClient.source(aPacket.source); + sourceClient.source(function (aResponse) { + do_check_true(!aResponse.error, + "Should not be an error grabbing the source"); + do_check_eq(aResponse.source, "initial content", + "The correct source content should be sent"); + + writeFile("temp.js", "new content"); + + sourceClient.source(function (aResponse) { + do_check_true(!aResponse.error, + "Should not be an error grabbing the source"); + do_check_eq(aResponse.source, "new content", + "The correct source content should not be cached, so we should get the new content"); + + do_get_file("temp.js").remove(false); + finishClient(gClient); + }); + }); +} diff --git a/devtools/server/tests/unit/test_sourcemaps-08.js b/devtools/server/tests/unit/test_sourcemaps-08.js new file mode 100644 index 000000000..b23665e43 --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-08.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Regression test for bug 882986 regarding sourcesContent and absolute source + * URLs. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-source-map"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-source-map", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_source_maps(); + }); + }); + do_test_pending(); +} + +function test_source_maps() +{ + gThreadClient.addOneTimeListener("newSource", function (aEvent, aPacket) { + let sourceClient = gThreadClient.source(aPacket.source); + sourceClient.source(function ({error, source}) { + do_check_true(!error, "should be able to grab the source"); + do_check_eq(source, "foo", + "Should load the source from the sourcesContent field"); + finishClient(gClient); + }); + }); + + let code = "'nothing here';\n"; + code += "//# sourceMappingURL=data:text/json," + JSON.stringify({ + version: 3, + file: "foo.js", + sources: ["/a"], + names: [], + mappings: "AACA", + sourcesContent: ["foo"] + }); + Components.utils.evalInSandbox(code, gDebuggee, "1.8", + "http://example.com/foo.js", 1); +} diff --git a/devtools/server/tests/unit/test_sourcemaps-09.js b/devtools/server/tests/unit/test_sourcemaps-09.js new file mode 100644 index 000000000..c317cf723 --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-09.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that source maps and breakpoints work with minified javascript. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-source-map"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-source-map", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_minified(); + }); + }); + do_test_pending(); +} + +function test_minified() +{ + let newSourceFired = false; + + gThreadClient.addOneTimeListener("newSource", function _onNewSource(aEvent, aPacket) { + do_check_eq(aEvent, "newSource"); + do_check_eq(aPacket.type, "newSource"); + do_check_true(!!aPacket.source); + + do_check_eq(aPacket.source.url, "http://example.com/foo.js", + "The new source should be foo.js"); + do_check_eq(aPacket.source.url.indexOf("foo.min.js"), -1, + "The new source should not be the minified file"); + + newSourceFired = true; + }); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aEvent, "paused"); + do_check_eq(aPacket.why.type, "debuggerStatement"); + + let location = { + line: 5 + }; + + getSource(gThreadClient, "http://example.com/foo.js").then(source => { + source.setBreakpoint(location, function (aResponse, bpClient) { + do_check_true(!aResponse.error); + testHitBreakpoint(); + }); + }); + }); + + // This is the original foo.js, which was then minified with uglifyjs version + // 2.2.5 and the "--mangle" option. + // + // (function () { + // debugger; + // function foo(n) { + // var bar = n + n; + // var unused = null; + // return bar; + // } + // for (var i = 0; i < 10; i++) { + // foo(i); + // } + // }()); + + let code = '(function(){debugger;function r(r){var n=r+r;var u=null;return n}for(var n=0;n<10;n++){r(n)}})();\n//# sourceMappingURL=data:text/json,{"file":"foo.min.js","version":3,"sources":["foo.js"],"names":["foo","n","bar","unused","i"],"mappings":"CAAC,WACC,QACA,SAASA,GAAIC,GACX,GAAIC,GAAMD,EAAIA,CACd,IAAIE,GAAS,IACb,OAAOD,GAET,IAAK,GAAIE,GAAI,EAAGA,EAAI,GAAIA,IAAK,CAC3BJ,EAAII"}'; + + Components.utils.evalInSandbox(code, gDebuggee, "1.8", + "http://example.com/foo.min.js", 1); +} + +function testHitBreakpoint(timesHit = 0) { + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + ++timesHit; + + do_check_eq(aEvent, "paused"); + do_check_eq(aPacket.why.type, "breakpoint"); + + if (timesHit === 10) { + gThreadClient.resume(() => finishClient(gClient)); + } else { + testHitBreakpoint(timesHit); + } + }); + + gThreadClient.resume(); +} diff --git a/devtools/server/tests/unit/test_sourcemaps-10.js b/devtools/server/tests/unit/test_sourcemaps-10.js new file mode 100644 index 000000000..e955dc8fb --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-10.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that we source map frame locations for the frame we are paused at. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +const {SourceNode} = require("source-map"); + +function run_test() { + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-source-map"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-source-map", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + promise.resolve(define_code()) + .then(run_code) + .then(test_frame_location) + .then(null, error => { + dump(error + "\n"); + dump(error.stack); + do_check_true(false); + }) + .then(() => { + finishClient(gClient); + }); + }); + }); + do_test_pending(); +} + +function define_code() { + let { code, map } = (new SourceNode(null, null, null, [ + new SourceNode(1, 0, "a.js", "function a() {\n"), + new SourceNode(2, 0, "a.js", " b();\n"), + new SourceNode(3, 0, "a.js", "}\n"), + new SourceNode(1, 0, "b.js", "function b() {\n"), + new SourceNode(2, 0, "b.js", " c();\n"), + new SourceNode(3, 0, "b.js", "}\n"), + new SourceNode(1, 0, "c.js", "function c() {\n"), + new SourceNode(2, 0, "c.js", " debugger;\n"), + new SourceNode(3, 0, "c.js", "}\n"), + ])).toStringWithSourceMap({ + file: "abc.js", + sourceRoot: "http://example.com/www/js/" + }); + + code += "//# sourceMappingURL=data:text/json," + map.toString(); + + Components.utils.evalInSandbox(code, gDebuggee, "1.8", + "http://example.com/www/js/abc.js", 1); +} + +function run_code() { + const d = promise.defer(); + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + d.resolve(aPacket); + gThreadClient.resume(); + }); + gDebuggee.a(); + return d.promise; +} + +function test_frame_location({ frame: { where: { source, line, column } } }) { + do_check_eq(source.url, "http://example.com/www/js/c.js"); + do_check_eq(line, 2); + do_check_eq(column, 0); +} diff --git a/devtools/server/tests/unit/test_sourcemaps-11.js b/devtools/server/tests/unit/test_sourcemaps-11.js new file mode 100644 index 000000000..e598c4c09 --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-11.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that we source map frame locations returned by "frames" requests. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +const {SourceNode} = require("source-map"); + +function run_test() { + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-source-map"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-source-map", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + promise.resolve(define_code()) + .then(run_code) + .then(test_frames) + .then(null, error => { + dump(error + "\n"); + dump(error.stack); + do_check_true(false); + }) + .then(() => { + finishClient(gClient); + }); + }); + }); + do_test_pending(); +} + +function define_code() { + let { code, map } = (new SourceNode(null, null, null, [ + new SourceNode(1, 0, "a.js", "function a() {\n"), + new SourceNode(2, 0, "a.js", " b();\n"), + new SourceNode(3, 0, "a.js", "}\n"), + new SourceNode(1, 0, "b.js", "function b() {\n"), + new SourceNode(2, 0, "b.js", " c();\n"), + new SourceNode(3, 0, "b.js", "}\n"), + new SourceNode(1, 0, "c.js", "function c() {\n"), + new SourceNode(2, 0, "c.js", " debugger;\n"), + new SourceNode(3, 0, "c.js", "}\n"), + ])).toStringWithSourceMap({ + file: "abc.js", + sourceRoot: "http://example.com/www/js/" + }); + + code += "//# sourceMappingURL=data:text/json," + map.toString(); + + Components.utils.evalInSandbox(code, gDebuggee, "1.8", + "http://example.com/www/js/abc.js", 1); +} + +function run_code() { + const d = promise.defer(); + gClient.addOneTimeListener("paused", function () { + gThreadClient.getFrames(0, 3, function (aResponse) { + d.resolve(aResponse); + gThreadClient.resume(); + }); + }); + gDebuggee.a(); + return d.promise; +} + +function test_frames({ error, frames }) { + do_check_true(!error); + do_check_eq(frames.length, 3); + check_frame(frames[0], "http://example.com/www/js/c.js"); + check_frame(frames[1], "http://example.com/www/js/b.js"); + check_frame(frames[2], "http://example.com/www/js/a.js"); +} + +function check_frame({ where: { source, line, column } }, aExpectedUrl) { + do_check_eq(source.url, aExpectedUrl); + do_check_eq(line, 2); + do_check_eq(column, 0); +} diff --git a/devtools/server/tests/unit/test_sourcemaps-12.js b/devtools/server/tests/unit/test_sourcemaps-12.js new file mode 100644 index 000000000..cb7f29920 --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-12.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that we continue stepping when a single original source's line + * corresponds to multiple generated js lines. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +const {SourceNode} = require("source-map"); + +function run_test() { + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-source-map"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-source-map", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + define_code(); + }); + }); + do_test_pending(); +} + +function define_code() { + let { code, map } = (new SourceNode(null, null, null, [ + new SourceNode(1, 0, "a.js", "function runTest() {\n"), + // A bunch of js lines map to the same original source line. + new SourceNode(2, 0, "a.js", " debugger;\n"), + new SourceNode(2, 0, "a.js", " var sum = 0;\n"), + new SourceNode(2, 0, "a.js", " for (var i = 0; i < 5; i++) {\n"), + new SourceNode(2, 0, "a.js", " sum += i;\n"), + new SourceNode(2, 0, "a.js", " }\n"), + // And now we have a new line in the original source that we should stop at. + new SourceNode(3, 0, "a.js", " sum;\n"), + new SourceNode(3, 0, "a.js", "}\n"), + ])).toStringWithSourceMap({ + file: "abc.js", + sourceRoot: "http://example.com/" + }); + + code += "//# sourceMappingURL=data:text/json," + map.toString(); + + Components.utils.evalInSandbox(code, gDebuggee, "1.8", + "http://example.com/abc.js", 1); + + run_code(); +} + +function run_code() { + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aPacket.why.type, "debuggerStatement"); + step_in(); + }); + gDebuggee.runTest(); +} + +function step_in() { + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { + do_check_eq(aPacket.why.type, "resumeLimit"); + let { frame: { environment, where: { source, line } } } = aPacket; + // Stepping should have moved us to the next source mapped line. + do_check_eq(source.url, "http://example.com/a.js"); + do_check_eq(line, 3); + // Which should have skipped over the for loop in the generated js and sum + // should be calculated. + do_check_eq(environment.bindings.variables.sum.value, 10); + finishClient(gClient); + }); + gThreadClient.stepIn(); +} + diff --git a/devtools/server/tests/unit/test_sourcemaps-13.js b/devtools/server/tests/unit/test_sourcemaps-13.js new file mode 100644 index 000000000..203731fda --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-13.js @@ -0,0 +1,105 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we don't permanently cache source maps across reloads. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gTabClient; + +const {SourceNode} = require("source-map"); + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-source-map"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-source-map", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + gTabClient = aTabClient; + setup_code(); + }); + }); + do_test_pending(); +} + +// The MAP_FILE_NAME is .txt so that the OS will definitely have an extension -> +// content type mapping for the extension. If it doesn't (like .map or .json), +// it logs console errors, which cause the test to fail. See bug 907839. +const MAP_FILE_NAME = "temporary-generated.txt"; + +const TEMP_FILE_1 = "temporary1.js"; +const TEMP_FILE_2 = "temporary2.js"; +const TEMP_GENERATED_SOURCE = "temporary-generated.js"; + +function setup_code() { + let node = new SourceNode(1, 0, + getFileUrl(TEMP_FILE_1, true), + "function temporary1() {}\n"); + let { code, map } = node.toStringWithSourceMap({ + file: getFileUrl(TEMP_GENERATED_SOURCE, true) + }); + + code += "//# sourceMappingURL=" + getFileUrl(MAP_FILE_NAME, true); + writeFile(MAP_FILE_NAME, map.toString()); + + Cu.evalInSandbox(code, + gDebuggee, + "1.8", + getFileUrl(TEMP_GENERATED_SOURCE, true), + 1); + + test_initial_sources(); +} + +function test_initial_sources() { + gThreadClient.getSources(function ({ error, sources }) { + do_check_true(!error); + sources = sources.filter(source => source.url); + do_check_eq(sources.length, 1); + do_check_eq(sources[0].url, getFileUrl(TEMP_FILE_1, true)); + reload(gTabClient).then(setup_new_code); + }); +} + +function setup_new_code() { + let node = new SourceNode(1, 0, + getFileUrl(TEMP_FILE_2, true), + "function temporary2() {}\n"); + let { code, map } = node.toStringWithSourceMap({ + file: getFileUrl(TEMP_GENERATED_SOURCE, true) + }); + + code += "\n//# sourceMappingURL=" + getFileUrl(MAP_FILE_NAME, true); + writeFile(MAP_FILE_NAME, map.toString()); + + gThreadClient.addOneTimeListener("newSource", test_new_sources); + Cu.evalInSandbox(code, + gDebuggee, + "1.8", + getFileUrl(TEMP_GENERATED_SOURCE, true), + 1); +} + +function test_new_sources() { + gThreadClient.getSources(function ({ error, sources }) { + do_check_true(!error); + sources = sources.filter(source => source.url); + + // Should now have TEMP_FILE_2 as a source. + do_check_eq(sources.length, 1); + let s = sources.filter(s => s.url === getFileUrl(TEMP_FILE_2, true))[0]; + do_check_true(!!s); + + finish_test(); + }); +} + +function finish_test() { + do_get_file(MAP_FILE_NAME).remove(false); + finishClient(gClient); +} diff --git a/devtools/server/tests/unit/test_sourcemaps-16.js b/devtools/server/tests/unit/test_sourcemaps-16.js new file mode 100644 index 000000000..4df9ece23 --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-16.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Verify that we can load the contents of every source in a source map produced + * by babel and browserify. + */ + +var gDebuggee; +var gClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-sourcemaps"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestThread(gClient, "test-sourcemaps", testSourcemap); + }); + do_test_pending(); +} + +const testSourcemap = Task.async(function* (threadResponse, tabClient, threadClient, tabResponse) { + evalTestCode(); + + const { sources } = yield getSources(threadClient); + + for (let form of sources) { + let sourceResponse = yield getSourceContent(threadClient.source(form)); + ok(sourceResponse, "Should be able to get the source response"); + ok(sourceResponse.source, "Should have the source text as well"); + } + + finishClient(gClient); +}); + +const TEST_FILE = "babel_and_browserify_script_with_source_map.js"; + +function evalTestCode() { + const testFileContents = readFile(TEST_FILE); + Cu.evalInSandbox(testFileContents, + gDebuggee, + "1.8", + getFileUrl(TEST_FILE), + 1); +} diff --git a/devtools/server/tests/unit/test_sourcemaps-17.js b/devtools/server/tests/unit/test_sourcemaps-17.js new file mode 100644 index 000000000..85da95972 --- /dev/null +++ b/devtools/server/tests/unit/test_sourcemaps-17.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that we properly handle frames that cannot be sourcemapped + * when using sourcemaps. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +const {SourceNode} = require("source-map"); + +function run_test() { + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-source-map"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-source-map", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_source_map(); + }); + }); + do_test_pending(); +} + +function test_source_map() { + // Set up debuggee code. + const a = new SourceNode(1, 1, "a.js", "function a() { b(); }"); + const b = new SourceNode(null, null, null, "function b() { c(); }"); + const c = new SourceNode(1, 1, "c.js", "function c() { d(); }"); + const d = new SourceNode(null, null, null, "function d() { e(); }"); + const e = new SourceNode(1, 1, "e.js", "function e() { debugger; }"); + const { map, code } = (new SourceNode(null, null, null, [a, b, c, d, e])).toStringWithSourceMap({ + file: "root.js", + sourceRoot: "root", + }); + Components.utils.evalInSandbox( + code + "//# sourceMappingURL=data:text/json;base64," + btoa(map.toString()), + gDebuggee, + "1.8", + "http://example.com/www/js/abc.js", + 1 + ); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.getFrames(0, 50, function ({ error, frames }) { + do_check_true(!error); + do_check_eq(frames.length, 4); + // b.js should be skipped + do_check_eq(frames[0].where.source.url, "http://example.com/www/root/e.js"); + do_check_eq(frames[1].where.source.url, "http://example.com/www/root/c.js"); + do_check_eq(frames[2].where.source.url, "http://example.com/www/root/a.js"); + do_check_eq(frames[3].where.source.url, null); + + finishClient(gClient); + }); + }); + + // Trigger it. + gDebuggee.eval("a()"); +} diff --git a/devtools/server/tests/unit/test_stepping-01.js b/devtools/server/tests/unit/test_stepping-01.js new file mode 100644 index 000000000..593a485a1 --- /dev/null +++ b/devtools/server/tests/unit/test_stepping-01.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check basic step-over functionality. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_stepping(); + }); + }); +} + +function test_simple_stepping() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 2); + do_check_eq(aPacket.why.type, "resumeLimit"); + // Check that stepping worked. + do_check_eq(gDebuggee.a, undefined); + do_check_eq(gDebuggee.b, undefined); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 3); + do_check_eq(aPacket.why.type, "resumeLimit"); + // Check that stepping worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, undefined); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + // When leaving a stack frame the line number doesn't change. + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 3); + do_check_eq(aPacket.why.type, "resumeLimit"); + // Check that stepping worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, 2); + + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + gThreadClient.stepOver(); + }); + gThreadClient.stepOver(); + + }); + gThreadClient.stepOver(); + + }); + + gDebuggee.eval("var line0 = Error().lineNumber;\n" + + "debugger;\n" + // line0 + 1 + "var a = 1;\n" + // line0 + 2 + "var b = 2;\n"); // line0 + 3 +} diff --git a/devtools/server/tests/unit/test_stepping-02.js b/devtools/server/tests/unit/test_stepping-02.js new file mode 100644 index 000000000..eaee099b9 --- /dev/null +++ b/devtools/server/tests/unit/test_stepping-02.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check basic step-in functionality. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_stepping(); + }); + }); +} + +function test_simple_stepping() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 2); + do_check_eq(aPacket.why.type, "resumeLimit"); + // Check that stepping worked. + do_check_eq(gDebuggee.a, undefined); + do_check_eq(gDebuggee.b, undefined); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 3); + do_check_eq(aPacket.why.type, "resumeLimit"); + // Check that stepping worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, undefined); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + // When leaving a stack frame the line number doesn't change. + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 3); + do_check_eq(aPacket.why.type, "resumeLimit"); + // Check that stepping worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, 2); + + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + gThreadClient.stepIn(); + }); + gThreadClient.stepIn(); + + }); + gThreadClient.stepIn(); + + }); + + gDebuggee.eval("var line0 = Error().lineNumber;\n" + + "debugger;\n" + // line0 + 1 + "var a = 1;\n" + // line0 + 2 + "var b = 2;\n"); // line0 + 3 +} diff --git a/devtools/server/tests/unit/test_stepping-03.js b/devtools/server/tests/unit/test_stepping-03.js new file mode 100644 index 000000000..6123928d7 --- /dev/null +++ b/devtools/server/tests/unit/test_stepping-03.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check basic step-out functionality. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_stepping(); + }); + }); +} + +function test_simple_stepping() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 5); + do_check_eq(aPacket.why.type, "resumeLimit"); + // Check that stepping worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, 2); + + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + gThreadClient.stepOut(); + + }); + + gDebuggee.eval("var line0 = Error().lineNumber;\n" + + "function f() {\n" + // line0 + 1 + " debugger;\n" + // line0 + 2 + " this.a = 1;\n" + // line0 + 3 + " this.b = 2;\n" + // line0 + 4 + "}\n" + // line0 + 5 + "f();\n"); // line0 + 6 +} diff --git a/devtools/server/tests/unit/test_stepping-04.js b/devtools/server/tests/unit/test_stepping-04.js new file mode 100644 index 000000000..efe52200d --- /dev/null +++ b/devtools/server/tests/unit/test_stepping-04.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that stepping over a function call does not pause inside the function. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_simple_stepping(); + }); + }); +} + +function test_simple_stepping() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 5); + do_check_eq(aPacket.why.type, "resumeLimit"); + // Check that stepping worked. + do_check_eq(gDebuggee.a, undefined); + do_check_eq(gDebuggee.b, undefined); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 6); + do_check_eq(aPacket.why.type, "resumeLimit"); + // Check that stepping worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, undefined); + + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + gThreadClient.stepOver(); + + }); + gThreadClient.stepOver(); + + }); + + gDebuggee.eval("var line0 = Error().lineNumber;\n" + + "function f() {\n" + // line0 + 1 + " this.a = 1;\n" + // line0 + 2 + "}\n" + // line0 + 3 + "debugger;\n" + // line0 + 4 + "f();\n" + // line0 + 5 + "let b = 2;\n"); // line0 + 6 +} diff --git a/devtools/server/tests/unit/test_stepping-05.js b/devtools/server/tests/unit/test_stepping-05.js new file mode 100644 index 000000000..2ab4570af --- /dev/null +++ b/devtools/server/tests/unit/test_stepping-05.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Make sure that stepping in the last statement of the last frame doesn't + * cause an unexpected pause, when another JS frame is pushed on the stack + * (bug 785689). + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_stepping_last(); + }); + }); +} + +function test_stepping_last() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 2); + do_check_eq(aPacket.why.type, "resumeLimit"); + // Check that stepping worked. + do_check_eq(gDebuggee.a, undefined); + do_check_eq(gDebuggee.b, undefined); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 3); + do_check_eq(aPacket.why.type, "resumeLimit"); + // Check that stepping worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, undefined); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + // When leaving a stack frame the line number doesn't change. + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 3); + do_check_eq(aPacket.why.type, "resumeLimit"); + // Check that stepping worked. + do_check_eq(gDebuggee.a, 1); + do_check_eq(gDebuggee.b, 2); + + gThreadClient.stepIn(function () { + test_next_pause(); + }); + }); + gThreadClient.stepIn(); + }); + gThreadClient.stepIn(); + + }); + gThreadClient.stepIn(); + + }); + + gDebuggee.eval("var line0 = Error().lineNumber;\n" + + "debugger;\n" + // line0 + 1 + "var a = 1;\n" + // line0 + 2 + "var b = 2;\n"); // line0 + 3 +} + +function test_next_pause() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check the return value. + do_check_eq(aPacket.type, "paused"); + // Before fixing bug 785689, the type was resumeLimit. + do_check_eq(aPacket.why.type, "debuggerStatement"); + + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + + gDebuggee.eval("debugger;"); +} diff --git a/devtools/server/tests/unit/test_stepping-06.js b/devtools/server/tests/unit/test_stepping-06.js new file mode 100644 index 000000000..49689f830 --- /dev/null +++ b/devtools/server/tests/unit/test_stepping-06.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that stepping out of a function returns the right return value. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gCallback; + +function run_test() +{ + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); + do_test_pending(); +} + +function run_test_with_server(aServer, aCallback) +{ + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stack", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-stack", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + // XXX: We have to do an executeSoon so that the error isn't caught and + // reported by DebuggerClient.requester (because we are using the local + // transport and share a stack) which causes the test to fail. + Services.tm.mainThread.dispatch({ + run: test_simple_stepping + }, Ci.nsIThread.DISPATCH_NORMAL); + }); + }); +} + +function test_simple_stepping() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check that the return value is 10. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 5); + do_check_eq(aPacket.why.type, "resumeLimit"); + do_check_eq(aPacket.why.frameFinished.return, 10); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check that the return value is undefined. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 8); + do_check_eq(aPacket.why.type, "resumeLimit"); + do_check_eq(aPacket.why.frameFinished.return.type, "undefined"); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Check that the exception was thrown. + do_check_eq(aPacket.type, "paused"); + do_check_eq(aPacket.frame.where.line, gDebuggee.line0 + 11); + do_check_eq(aPacket.why.type, "resumeLimit"); + do_check_eq(aPacket.why.frameFinished.throw, "ah"); + + gThreadClient.resume(function () { + gClient.close().then(gCallback); + }); + }); + gThreadClient.stepOut(); + }); + gThreadClient.resume(); + }); + gThreadClient.stepOut(); + }); + gThreadClient.resume(); + }); + gThreadClient.stepOut(); + + }); + + gDebuggee.eval("var line0 = Error().lineNumber;\n" + + "function f() {\n" + // line0 + 1 + " debugger;\n" + // line0 + 2 + " var a = 10;\n" + // line0 + 3 + " return a;\n" + // line0 + 4 + "}\n" + // line0 + 5 + "function g() {\n" + // line0 + 6 + " debugger;\n" + // line0 + 7 + "}\n" + // line0 + 8 + "function h() {\n" + // line0 + 9 + " debugger;\n" + // line0 + 10 + " throw 'ah';\n" + // line0 + 11 + " return 2;\n" + // line0 + 12 + "}\n" + // line0 + 13 + "f();\n" + // line0 + 14 + "g();\n" + // line0 + 15 + "try { h() } catch (ex) { };\n"); // line0 + 16 +} diff --git a/devtools/server/tests/unit/test_stepping-07.js b/devtools/server/tests/unit/test_stepping-07.js new file mode 100644 index 000000000..9ad0d79ff --- /dev/null +++ b/devtools/server/tests/unit/test_stepping-07.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that stepping over an implicit return makes sense. Bug 1155966. + */ + +var gDebuggee; +var gClient; +var gCallback; + +function run_test() { + do_test_pending(); + run_test_with_server(DebuggerServer, function () { + run_test_with_server(WorkerDebuggerServer, do_test_finished); + }); +} + +function run_test_with_server(aServer, aCallback) { + gCallback = aCallback; + initTestDebuggerServer(aServer); + gDebuggee = addTestGlobal("test-stepping", aServer); + gClient = new DebuggerClient(aServer.connectPipe()); + gClient.connect(testSteppingAndReturns); +} + +const testSteppingAndReturns = Task.async(function* () { + const [attachResponse, tabClient, threadClient] = yield attachTestTabAndResume(gClient, "test-stepping"); + ok(!attachResponse.error, "Should not get an error attaching"); + + dumpn("Evaluating test code and waiting for first debugger statement"); + const dbgStmt1 = yield executeOnNextTickAndWaitForPause(evaluateTestCode, gClient); + equal(dbgStmt1.frame.where.line, 3, + "Should be at debugger statement on line 3"); + + dumpn("Testing stepping with implicit return"); + const step1 = yield stepOver(gClient, threadClient); + equal(step1.frame.where.line, 4, "Should step to line 4"); + const step2 = yield stepOver(gClient, threadClient); + equal(step2.frame.where.line, 7, + "Should step to line 7, the implicit return at the last line of the function"); + // This assertion doesn't pass yet. You would need to do *another* + // step at the end of this function to get the frameFinished. + // See bug 923975. + // + // ok(step2.why.frameFinished, "This should be the implicit function return"); + + dumpn("Continuing and waiting for second debugger statement"); + const dbgStmt2 = yield resumeAndWaitForPause(gClient, threadClient); + equal(dbgStmt2.frame.where.line, 12, + "Should be at debugger statement on line 3"); + + dumpn("Testing stepping with explicit return"); + const step3 = yield stepOver(gClient, threadClient); + equal(step3.frame.where.line, 13, "Should step to line 13"); + const step4 = yield stepOver(gClient, threadClient); + equal(step4.frame.where.line, 15, "Should step out of the function from line 15"); + // This step is a bit funny, see bug 1013219 for details. + const step5 = yield stepOver(gClient, threadClient); + equal(step5.frame.where.line, 15, "Should step out of the function from line 15"); + ok(step5.why.frameFinished, "This should be the explicit function return"); + + finishClient(gClient, gCallback); +}); + +function evaluateTestCode() { + Cu.evalInSandbox( + ` // 1 + function implicitReturn() { // 2 + debugger; // 3 + if (this.someUndefinedProperty) { // 4 + yikes(); // 5 + } // 6 + } // 7 + // 8 + var yes = true; // 9 + function explicitReturn() { // 10 + if (yes) { // 11 + debugger; // 12 + return 1; // 13 + } // 14 + } // 15 + // 16 + implicitReturn(); // 17 + explicitReturn(); // 18 + `, // 19 + gDebuggee, + "1.8", + "test_stepping-07-test-code.js", + 1 + ); +} diff --git a/devtools/server/tests/unit/test_symbols-01.js b/devtools/server/tests/unit/test_symbols-01.js new file mode 100644 index 000000000..8ea26086a --- /dev/null +++ b/devtools/server/tests/unit/test_symbols-01.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we can represent ES6 Symbols over the RDP. + */ + +const URL = "foo.js"; + +function run_test() { + initTestDebuggerServer(); + const debuggee = addTestGlobal("test-symbols"); + const client = new DebuggerClient(DebuggerServer.connectPipe()); + + client.connect().then(function () { + attachTestTabAndResume(client, "test-symbols", function (response, tabClient, threadClient) { + add_task(testSymbols.bind(null, client, debuggee)); + run_next_test(); + }); + }); + + do_test_pending(); +} + +function* testSymbols(client, debuggee) { + const evalCode = () => { + Components.utils.evalInSandbox( + "(" + function () { + var symbolWithName = Symbol("Chris"); + var symbolWithoutName = Symbol(); + var iteratorSymbol = Symbol.iterator; + debugger; + } + "())", + debuggee, + "1.8", + URL, + 1 + ); + }; + + const packet = yield executeOnNextTickAndWaitForPause(evalCode, client); + const { + symbolWithName, + symbolWithoutName, + iteratorSymbol + } = packet.frame.environment.bindings.variables; + + equal(symbolWithName.value.type, "symbol"); + equal(symbolWithName.value.name, "Chris"); + + equal(symbolWithoutName.value.type, "symbol"); + ok(!("name" in symbolWithoutName.value)); + + equal(iteratorSymbol.value.type, "symbol"); + equal(iteratorSymbol.value.name, "Symbol.iterator"); + + finishClient(client); +} diff --git a/devtools/server/tests/unit/test_symbols-02.js b/devtools/server/tests/unit/test_symbols-02.js new file mode 100644 index 000000000..f80dc3322 --- /dev/null +++ b/devtools/server/tests/unit/test_symbols-02.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that we don't run debuggee code when getting symbol names. + */ + +const URL = "foo.js"; + +function run_test() { + initTestDebuggerServer(); + const debuggee = addTestGlobal("test-symbols"); + const client = new DebuggerClient(DebuggerServer.connectPipe()); + + client.connect().then(function () { + attachTestTabAndResume(client, "test-symbols", function (response, tabClient, threadClient) { + add_task(testSymbols.bind(null, client, debuggee)); + run_next_test(); + }); + }); + + do_test_pending(); +} + +function* testSymbols(client, debuggee) { + const evalCode = () => { + Components.utils.evalInSandbox( + "(" + function () { + Symbol.prototype.toString = () => { + throw new Error("lololol"); + }; + var sym = Symbol("le troll"); + debugger; + } + "())", + debuggee, + "1.8", + URL, + 1 + ); + }; + + const packet = yield executeOnNextTickAndWaitForPause(evalCode, client); + const { sym } = packet.frame.environment.bindings.variables; + + equal(sym.value.type, "symbol"); + equal(sym.value.name, "le troll"); + + finishClient(client); +} diff --git a/devtools/server/tests/unit/test_threadlifetime-01.js b/devtools/server/tests/unit/test_threadlifetime-01.js new file mode 100644 index 000000000..1325a45fa --- /dev/null +++ b/devtools/server/tests/unit/test_threadlifetime-01.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that thread-lifetime grips last past a resume. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-grips"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_thread_lifetime(); + }); + }); + do_test_pending(); +} + +function test_thread_lifetime() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let pauseGrip = aPacket.frame.arguments[0]; + + // Create a thread-lifetime actor for this object. + gClient.request({ to: pauseGrip.actor, type: "threadGrip" }, function (aResponse) { + // Successful promotion won't return an error. + do_check_eq(aResponse.error, undefined); + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Verify that the promoted actor is returned again. + do_check_eq(pauseGrip.actor, aPacket.frame.arguments[0].actor); + // Now that we've resumed, should get unrecognizePacketType for the + // promoted grip. + gClient.request({ to: pauseGrip.actor, type: "bogusRequest"}, function (aResponse) { + do_check_eq(aResponse.error, "unrecognizedPacketType"); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + gThreadClient.resume(); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe(arg1) { + debugger; + debugger; + } + stopMe({obj: true}); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_threadlifetime-02.js b/devtools/server/tests/unit/test_threadlifetime-02.js new file mode 100644 index 000000000..a7d21a7f9 --- /dev/null +++ b/devtools/server/tests/unit/test_threadlifetime-02.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that thread-lifetime grips last past a resume. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-grips"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_thread_lifetime(); + }); + }); + do_test_pending(); +} + +function test_thread_lifetime() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let pauseGrip = aPacket.frame.arguments[0]; + + // Create a thread-lifetime actor for this object. + gClient.request({ to: pauseGrip.actor, type: "threadGrip" }, function (aResponse) { + // Successful promotion won't return an error. + do_check_eq(aResponse.error, undefined); + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Verify that the promoted actor is returned again. + do_check_eq(pauseGrip.actor, aPacket.frame.arguments[0].actor); + // Now that we've resumed, release the thread-lifetime grip. + gClient.release(pauseGrip.actor, function (aResponse) { + gClient.request({ to: pauseGrip.actor, type: "bogusRequest" }, function (aResponse) { + do_check_eq(aResponse.error, "noSuchActor"); + gThreadClient.resume(function (aResponse) { + finishClient(gClient); + }); + }); + }); + }); + gThreadClient.resume(); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe(arg1) { + debugger; + debugger; + } + stopMe({obj: true}); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_threadlifetime-03.js b/devtools/server/tests/unit/test_threadlifetime-03.js new file mode 100644 index 000000000..22b707ff8 --- /dev/null +++ b/devtools/server/tests/unit/test_threadlifetime-03.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that thread-lifetime grips last past a resume. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-grips"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_thread_lifetime(); + }); + }); + do_test_pending(); +} + +function test_thread_lifetime() +{ + // Get three thread-lifetime grips. + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let grips = []; + + let handler = function (aResponse) { + if (aResponse.error) { + do_check_eq(aResponse.error, ""); + finishClient(gClient); + } + grips.push(aResponse.from); + if (grips.length == 3) { + test_release_many(grips); + } + }; + for (let i = 0; i < 3; i++) { + gClient.request({ to: aPacket.frame.arguments[i].actor, type: "threadGrip" }, + handler); + } + }); + + gDebuggee.eval("(" + function () { + function stopMe(arg1, arg2, arg3) { + debugger; + } + stopMe({obj: 1}, {obj: 2}, {obj: 3}); + } + ")()"); +} + +function test_release_many(grips) +{ + // Release the first two grips, leave the third alive. + + let release = [grips[0], grips[1]]; + + gThreadClient.releaseMany(release, function (aResponse) { + // First two actors should return a noSuchActor error, because + // they're gone now. + gClient.request({ to: grips[0], type: "bogusRequest" }, function (aResponse) { + do_check_eq(aResponse.error, "noSuchActor"); + gClient.request({ to: grips[1], type: "bogusRequest" }, function (aResponse) { + do_check_eq(aResponse.error, "noSuchActor"); + + // Last actor should return unrecognizedPacketType, because it's still + // alive. + gClient.request({ to: grips[2], type: "bogusRequest" }, function (aResponse) { + do_check_eq(aResponse.error, "unrecognizedPacketType"); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + }); + }); +} diff --git a/devtools/server/tests/unit/test_threadlifetime-04.js b/devtools/server/tests/unit/test_threadlifetime-04.js new file mode 100644 index 000000000..aff8b525c --- /dev/null +++ b/devtools/server/tests/unit/test_threadlifetime-04.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that requesting a thread-lifetime actor twice for the same + * value returns the same actor. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-grips"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_thread_lifetime(); + }); + }); + do_test_pending(); +} + +function test_thread_lifetime() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let pauseGrip = aPacket.frame.arguments[0]; + + gClient.request({ to: pauseGrip.actor, type: "threadGrip" }, function (aResponse) { + // Successful promotion won't return an error. + do_check_eq(aResponse.error, undefined); + + let threadGrip1 = aResponse.from; + + gClient.request({ to: pauseGrip.actor, type: "threadGrip" }, function (aResponse) { + do_check_eq(threadGrip1, aResponse.from); + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe(arg1) { + debugger; + } + stopMe({obj: true}); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_threadlifetime-05.js b/devtools/server/tests/unit/test_threadlifetime-05.js new file mode 100644 index 000000000..6697947c1 --- /dev/null +++ b/devtools/server/tests/unit/test_threadlifetime-05.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Make sure that releasing a pause-lifetime actorin a releaseMany returns an + * error, but releases all the thread-lifetime actors. + */ + +var gDebuggee; +var gClient; +var gThreadClient; +var gPauseGrip; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-grips"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_thread_lifetime(); + }); + }); + do_test_pending(); +} + +function arg_grips(aFrameArgs, aOnResponse) { + let grips = []; + let handler = function (aResponse) { + if (aResponse.error) { + grips.push(aResponse.error); + } else { + grips.push(aResponse.from); + } + if (grips.length == aFrameArgs.length) { + aOnResponse(grips); + } + }; + for (let i = 0; i < aFrameArgs.length; i++) { + gClient.request({ to: aFrameArgs[i].actor, type: "threadGrip" }, + handler); + } +} + +function test_thread_lifetime() +{ + // Get two thread-lifetime grips. + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + + let frameArgs = [ aPacket.frame.arguments[0], aPacket.frame.arguments[1] ]; + gPauseGrip = aPacket.frame.arguments[2]; + arg_grips(frameArgs, function (aGrips) { + release_grips(frameArgs, aGrips); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe(arg1, arg2, arg3) { + debugger; + } + stopMe({obj: 1}, {obj: 2}, {obj: 3}); + } + ")()"); +} + + +function release_grips(aFrameArgs, aThreadGrips) +{ + // Release all actors with releaseMany... + let release = [aThreadGrips[0], aThreadGrips[1], gPauseGrip.actor]; + gThreadClient.releaseMany(release, function (aResponse) { + do_check_eq(aResponse.error, "notReleasable"); + // Now ask for thread grips again, they should not exist. + arg_grips(aFrameArgs, function (aNewGrips) { + for (let i = 0; i < aNewGrips.length; i++) { + do_check_eq(aNewGrips[i], "noSuchActor"); + } + gThreadClient.resume(function () { + finishClient(gClient); + }); + }); + }); +} diff --git a/devtools/server/tests/unit/test_threadlifetime-06.js b/devtools/server/tests/unit/test_threadlifetime-06.js new file mode 100644 index 000000000..dba0156f9 --- /dev/null +++ b/devtools/server/tests/unit/test_threadlifetime-06.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check that promoting multiple pause-lifetime actors to thread-lifetime actors + * works as expected. + */ + +var gDebuggee; +var gClient; +var gThreadClient; + +function run_test() +{ + initTestDebuggerServer(); + gDebuggee = addTestGlobal("test-grips"); + gClient = new DebuggerClient(DebuggerServer.connectPipe()); + gClient.connect().then(function () { + attachTestTabAndResume(gClient, "test-grips", function (aResponse, aTabClient, aThreadClient) { + gThreadClient = aThreadClient; + test_thread_lifetime(); + }); + }); + do_test_pending(); +} + +function test_thread_lifetime() +{ + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + let actors = []; + let last; + for (let aGrip of aPacket.frame.arguments) { + actors.push(aGrip.actor); + last = aGrip.actor; + } + + // Create thread-lifetime actors for these objects. + gThreadClient.threadGrips(actors, function (aResponse) { + // Successful promotion won't return an error. + do_check_eq(aResponse.error, undefined); + + gThreadClient.addOneTimeListener("paused", function (aEvent, aPacket) { + // Verify that the promoted actors are returned again. + actors.forEach(function (actor, i) { + do_check_eq(actor, aPacket.frame.arguments[i].actor); + }); + // Now that we've resumed, release the thread-lifetime grips. + gThreadClient.releaseMany(actors, function (aResponse) { + // Successful release won't return an error. + do_check_eq(aResponse.error, undefined); + + gClient.request({ to: last, type: "bogusRequest" }, function (aResponse) { + do_check_eq(aResponse.error, "noSuchActor"); + gThreadClient.resume(function (aResponse) { + finishClient(gClient); + }); + }); + }); + }); + gThreadClient.resume(); + }); + }); + + gDebuggee.eval("(" + function () { + function stopMe(arg1, arg2, arg3) { + debugger; + debugger; + } + stopMe({obj: 1}, {obj: 2}, {obj: 3}); + } + ")()"); +} diff --git a/devtools/server/tests/unit/test_unsafeDereference.js b/devtools/server/tests/unit/test_unsafeDereference.js new file mode 100644 index 000000000..d7afe645f --- /dev/null +++ b/devtools/server/tests/unit/test_unsafeDereference.js @@ -0,0 +1,134 @@ +// Any copyright is dedicated to the Public Domain. +// http://creativecommons.org/publicdomain/zero/1.0/ + +// Test Debugger.Object.prototype.unsafeDereference in the presence of +// interesting cross-compartment wrappers. +// +// This is not really a debugger server test; it's more of a Debugger test. +// But we need xpcshell and Components.utils.Sandbox to get +// cross-compartment wrappers with interesting properties, and this is the +// xpcshell test directory most closely related to the JS Debugger API. + +Components.utils.import("resource://gre/modules/jsdebugger.jsm"); +addDebuggerToGlobal(this); + +// Add a method to Debugger.Object for fetching value properties +// conveniently. +Debugger.Object.prototype.getProperty = function (aName) { + let desc = this.getOwnPropertyDescriptor(aName); + if (!desc) + return undefined; + if (!desc.value) { + throw Error("Debugger.Object.prototype.getProperty: " + + "not a value property: " + aName); + } + return desc.value; +}; + +function run_test() { + // Create a low-privilege sandbox, and a chrome-privilege sandbox. + let contentBox = Components.utils.Sandbox("http://www.example.com"); + let chromeBox = Components.utils.Sandbox(this); + + // Create an objects in this compartment, and one in each sandbox. We'll + // refer to the objects as "mainObj", "contentObj", and "chromeObj", in + // variable and property names. + var mainObj = { name: "mainObj" }; + Components.utils.evalInSandbox('var contentObj = { name: "contentObj" };', + contentBox); + Components.utils.evalInSandbox('var chromeObj = { name: "chromeObj" };', + chromeBox); + + // Give each global a pointer to all the other globals' objects. + contentBox.mainObj = chromeBox.mainObj = mainObj; + var contentObj = chromeBox.contentObj = contentBox.contentObj; + var chromeObj = contentBox.chromeObj = chromeBox.chromeObj; + + // First, a whole bunch of basic sanity checks, to ensure that JavaScript + // evaluated in various scopes really does see the world the way this + // test expects it to. + + // The objects appear as global variables in the sandbox, and as + // the sandbox object's properties in chrome. + do_check_true(Components.utils.evalInSandbox("mainObj", contentBox) + === contentBox.mainObj); + do_check_true(Components.utils.evalInSandbox("contentObj", contentBox) + === contentBox.contentObj); + do_check_true(Components.utils.evalInSandbox("chromeObj", contentBox) + === contentBox.chromeObj); + do_check_true(Components.utils.evalInSandbox("mainObj", chromeBox) + === chromeBox.mainObj); + do_check_true(Components.utils.evalInSandbox("contentObj", chromeBox) + === chromeBox.contentObj); + do_check_true(Components.utils.evalInSandbox("chromeObj", chromeBox) + === chromeBox.chromeObj); + + // We (the main global) can see properties of all objects in all globals. + do_check_true(contentBox.mainObj.name === "mainObj"); + do_check_true(contentBox.contentObj.name === "contentObj"); + do_check_true(contentBox.chromeObj.name === "chromeObj"); + + // chromeBox can see properties of all objects in all globals. + do_check_eq(Components.utils.evalInSandbox("mainObj.name", chromeBox), + "mainObj"); + do_check_eq(Components.utils.evalInSandbox("contentObj.name", chromeBox), + "contentObj"); + do_check_eq(Components.utils.evalInSandbox("chromeObj.name", chromeBox), + "chromeObj"); + + // contentBox can see properties of the content object, but not of either + // chrome object, because by default, content -> chrome wrappers hide all + // object properties. + do_check_eq(Components.utils.evalInSandbox("mainObj.name", contentBox), + undefined); + do_check_eq(Components.utils.evalInSandbox("contentObj.name", contentBox), + "contentObj"); + do_check_eq(Components.utils.evalInSandbox("chromeObj.name", contentBox), + undefined); + + // When viewing an object in compartment A from the vantage point of + // compartment B, Debugger should give the same results as debuggee code + // would. + + // Create a debugger, debugging our two sandboxes. + let dbg = new Debugger; + + // Create Debugger.Object instances referring to the two sandboxes, as + // seen from their own compartments. + let contentBoxDO = dbg.addDebuggee(contentBox); + let chromeBoxDO = dbg.addDebuggee(chromeBox); + + // Use Debugger to view the objects from contentBox. We should get the + // same D.O instance from both getProperty and makeDebuggeeValue, and the + // same property visibility we checked for above. + let mainFromContentDO = contentBoxDO.getProperty("mainObj"); + do_check_eq(mainFromContentDO, contentBoxDO.makeDebuggeeValue(mainObj)); + do_check_eq(mainFromContentDO.getProperty("name"), undefined); + do_check_eq(mainFromContentDO.unsafeDereference(), mainObj); + + let contentFromContentDO = contentBoxDO.getProperty("contentObj"); + do_check_eq(contentFromContentDO, contentBoxDO.makeDebuggeeValue(contentObj)); + do_check_eq(contentFromContentDO.getProperty("name"), "contentObj"); + do_check_eq(contentFromContentDO.unsafeDereference(), contentObj); + + let chromeFromContentDO = contentBoxDO.getProperty("chromeObj"); + do_check_eq(chromeFromContentDO, contentBoxDO.makeDebuggeeValue(chromeObj)); + do_check_eq(chromeFromContentDO.getProperty("name"), undefined); + do_check_eq(chromeFromContentDO.unsafeDereference(), chromeObj); + + // Similarly, viewing from chromeBox. + let mainFromChromeDO = chromeBoxDO.getProperty("mainObj"); + do_check_eq(mainFromChromeDO, chromeBoxDO.makeDebuggeeValue(mainObj)); + do_check_eq(mainFromChromeDO.getProperty("name"), "mainObj"); + do_check_eq(mainFromChromeDO.unsafeDereference(), mainObj); + + let contentFromChromeDO = chromeBoxDO.getProperty("contentObj"); + do_check_eq(contentFromChromeDO, chromeBoxDO.makeDebuggeeValue(contentObj)); + do_check_eq(contentFromChromeDO.getProperty("name"), "contentObj"); + do_check_eq(contentFromChromeDO.unsafeDereference(), contentObj); + + let chromeFromChromeDO = chromeBoxDO.getProperty("chromeObj"); + do_check_eq(chromeFromChromeDO, chromeBoxDO.makeDebuggeeValue(chromeObj)); + do_check_eq(chromeFromChromeDO.getProperty("name"), "chromeObj"); + do_check_eq(chromeFromChromeDO.unsafeDereference(), chromeObj); +} diff --git a/devtools/server/tests/unit/test_xpcshell_debugging.js b/devtools/server/tests/unit/test_xpcshell_debugging.js new file mode 100644 index 000000000..7026a02b3 --- /dev/null +++ b/devtools/server/tests/unit/test_xpcshell_debugging.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test the xpcshell-test debug support. Ideally we should have this test +// next to the xpcshell support code, but that's tricky... + +function run_test() { + let testFile = do_get_file("xpcshell_debugging_script.js"); + + // _setupDebuggerServer is from xpcshell-test's head.js + let testResumed = false; + let DebuggerServer = _setupDebuggerServer([testFile.path], () => testResumed = true); + let transport = DebuggerServer.connectPipe(); + let client = new DebuggerClient(transport); + client.connect().then(() => { + // Even though we have no tabs, listTabs gives us the chromeDebugger. + client.getProcess().then(response => { + let actor = response.form.actor; + client.attachTab(actor, (response, tabClient) => { + tabClient.attachThread(null, (response, threadClient) => { + threadClient.addOneTimeListener("paused", (event, packet) => { + equal(packet.why.type, "breakpoint", + "yay - hit the breakpoint at the first line in our script"); + // Resume again - next stop should be our "debugger" statement. + threadClient.addOneTimeListener("paused", (event, packet) => { + equal(packet.why.type, "debuggerStatement", + "yay - hit the 'debugger' statement in our script"); + threadClient.resume(() => { + finishClient(client); + }); + }); + threadClient.resume(); + }); + // tell the thread to do the initial resume. This would cause the + // xpcshell test harness to resume and load the file under test. + threadClient.resume(response => { + // should have been told to resume the test itself. + ok(testResumed); + // Now load our test script. + load(testFile.path); + // and our "paused" listener above should get hit. + }); + }); + }); + }); + }); + do_test_pending(); +} diff --git a/devtools/server/tests/unit/testactors.js b/devtools/server/tests/unit/testactors.js new file mode 100644 index 000000000..39564eeef --- /dev/null +++ b/devtools/server/tests/unit/testactors.js @@ -0,0 +1,176 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { ActorPool, appendExtraActors, createExtraActors } = require("devtools/server/actors/common"); +const { RootActor } = require("devtools/server/actors/root"); +const { ThreadActor } = require("devtools/server/actors/script"); +const { DebuggerServer } = require("devtools/server/main"); +const { TabSources } = require("devtools/server/actors/utils/TabSources"); +const promise = require("promise"); +const makeDebugger = require("devtools/server/actors/utils/make-debugger"); + +var gTestGlobals = []; +DebuggerServer.addTestGlobal = function (aGlobal) { + gTestGlobals.push(aGlobal); +}; + +DebuggerServer.getTestGlobal = function (name) { + for (let g of gTestGlobals) { + if (g.__name == name) { + return g; + } + } + + return null; +}; + +// A mock tab list, for use by tests. This simply presents each global in +// gTestGlobals as a tab, and the list is fixed: it never calls its +// onListChanged handler. +// +// As implemented now, we consult gTestGlobals when we're constructed, not +// when we're iterated over, so tests have to add their globals before the +// root actor is created. +function TestTabList(aConnection) { + this.conn = aConnection; + + // An array of actors for each global added with + // DebuggerServer.addTestGlobal. + this._tabActors = []; + + // A pool mapping those actors' names to the actors. + this._tabActorPool = new ActorPool(aConnection); + + for (let global of gTestGlobals) { + let actor = new TestTabActor(aConnection, global); + actor.selected = false; + this._tabActors.push(actor); + this._tabActorPool.addActor(actor); + } + if (this._tabActors.length > 0) { + this._tabActors[0].selected = true; + } + + aConnection.addActorPool(this._tabActorPool); +} + +TestTabList.prototype = { + constructor: TestTabList, + getList: function () { + return Promise.resolve([...this._tabActors]); + } +}; + +function createRootActor(aConnection) +{ + let root = new RootActor(aConnection, { + tabList: new TestTabList(aConnection), + globalActorFactories: DebuggerServer.globalActorFactories, + }); + + root.applicationType = "xpcshell-tests"; + return root; +} + +function TestTabActor(aConnection, aGlobal) +{ + this.conn = aConnection; + this._global = aGlobal; + this._global.wrappedJSObject = aGlobal; + this.threadActor = new ThreadActor(this, this._global); + this.conn.addActor(this.threadActor); + this._attached = false; + this._extraActors = {}; + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: () => [this._global], + shouldAddNewGlobalAsDebuggee: g => g.hostAnnotations && + g.hostAnnotations.type == "document" && + g.hostAnnotations.element === this._global + + }); +} + +TestTabActor.prototype = { + constructor: TestTabActor, + actorPrefix: "TestTabActor", + + get window() { + return this._global; + }, + + get url() { + return this._global.__name; + }, + + get sources() { + if (!this._sources) { + this._sources = new TabSources(this.threadActor); + } + return this._sources; + }, + + form: function () { + let response = { actor: this.actorID, title: this._global.__name }; + + // Walk over tab actors added by extensions and add them to a new ActorPool. + let actorPool = new ActorPool(this.conn); + this._createExtraActors(DebuggerServer.tabActorFactories, actorPool); + if (!actorPool.isEmpty()) { + this._tabActorPool = actorPool; + this.conn.addActorPool(this._tabActorPool); + } + + this._appendExtraActors(response); + + return response; + }, + + onAttach: function (aRequest) { + this._attached = true; + + let response = { type: "tabAttached", threadActor: this.threadActor.actorID }; + this._appendExtraActors(response); + + return response; + }, + + onDetach: function (aRequest) { + if (!this._attached) { + return { "error":"wrongState" }; + } + return { type: "detached" }; + }, + + onReload: function (aRequest) { + this.sources.reset({ sourceMaps: true }); + this.threadActor.clearDebuggees(); + this.threadActor.dbg.addDebuggees(); + return {}; + }, + + removeActorByName: function (aName) { + const actor = this._extraActors[aName]; + if (this._tabActorPool) { + this._tabActorPool.removeActor(actor); + } + delete this._extraActors[aName]; + }, + + /* Support for DebuggerServer.addTabActor. */ + _createExtraActors: createExtraActors, + _appendExtraActors: appendExtraActors +}; + +TestTabActor.prototype.requestTypes = { + "attach": TestTabActor.prototype.onAttach, + "detach": TestTabActor.prototype.onDetach, + "reload": TestTabActor.prototype.onReload +}; + +exports.register = function (handle) { + handle.setRootActor(createRootActor); +}; + +exports.unregister = function (handle) { + handle.setRootActor(null); +}; diff --git a/devtools/server/tests/unit/tracerlocations.js b/devtools/server/tests/unit/tracerlocations.js new file mode 100644 index 000000000..aa4677c0f --- /dev/null +++ b/devtools/server/tests/unit/tracerlocations.js @@ -0,0 +1,8 @@ +// For JS Tracer tests dealing with source locations. + +function foo(x) { + x += 6; + return "bar"; +} + +foo(42); diff --git a/devtools/server/tests/unit/xpcshell.ini b/devtools/server/tests/unit/xpcshell.ini new file mode 100644 index 000000000..703aa48fb --- /dev/null +++ b/devtools/server/tests/unit/xpcshell.ini @@ -0,0 +1,232 @@ +[DEFAULT] +tags = devtools +head = head_dbg.js +tail = +firefox-appdir = browser +skip-if = toolkit == 'android' + +support-files = + babel_and_browserify_script_with_source_map.js + source-map-data/sourcemapped.coffee + source-map-data/sourcemapped.map + post_init_global_actors.js + post_init_tab_actors.js + pre_init_global_actors.js + pre_init_tab_actors.js + registertestactors-01.js + registertestactors-02.js + registertestactors-03.js + sourcemapped.js + testactors.js + hello-actor.js + setBreakpoint-on-column.js + setBreakpoint-on-column-in-gcd-script.js + setBreakpoint-on-column-with-no-offsets.js + setBreakpoint-on-column-with-no-offsets-at-end-of-line.js + setBreakpoint-on-column-with-no-offsets-in-gcd-script.js + setBreakpoint-on-line.js + setBreakpoint-on-line-in-gcd-script.js + setBreakpoint-on-line-with-multiple-offsets.js + setBreakpoint-on-line-with-multiple-statements.js + setBreakpoint-on-line-with-no-offsets.js + setBreakpoint-on-line-with-no-offsets-in-gcd-script.js + addons/web-extension/manifest.json + addons/web-extension-upgrade/manifest.json + addons/web-extension2/manifest.json + +[test_addon_reload.js] +[test_addons_actor.js] +[test_animation_name.js] +[test_animation_type.js] +[test_actor-registry-actor.js] +[test_nesting-01.js] +[test_nesting-02.js] +[test_nesting-03.js] +[test_forwardingprefix.js] +[test_getyoungestframe.js] +[test_nsjsinspector.js] +[test_dbgactor.js] +[test_dbgglobal.js] +[test_dbgclient_debuggerstatement.js] +[test_attach.js] +[test_MemoryActor_saveHeapSnapshot_01.js] +[test_MemoryActor_saveHeapSnapshot_02.js] +[test_MemoryActor_saveHeapSnapshot_03.js] +[test_reattach-thread.js] +[test_blackboxing-01.js] +[test_blackboxing-02.js] +[test_blackboxing-03.js] +[test_blackboxing-04.js] +[test_blackboxing-05.js] +[test_blackboxing-06.js] +[test_blackboxing-07.js] +[test_frameactor-01.js] +[test_frameactor-02.js] +[test_frameactor-03.js] +[test_frameactor-04.js] +[test_frameactor-05.js] +[test_framearguments-01.js] +[test_getRuleText.js] +[test_getTextAtLineColumn.js] +[test_pauselifetime-01.js] +[test_pauselifetime-02.js] +[test_pauselifetime-03.js] +[test_pauselifetime-04.js] +[test_threadlifetime-01.js] +[test_threadlifetime-02.js] +[test_threadlifetime-03.js] +[test_threadlifetime-04.js] +[test_threadlifetime-05.js] +[test_threadlifetime-06.js] +[test_functiongrips-01.js] +[test_frameclient-01.js] +[test_frameclient-02.js] +[test_nativewrappers.js] +[test_nodelistactor.js] +[test_eval-01.js] +[test_eval-02.js] +[test_eval-03.js] +[test_eval-04.js] +[test_eval-05.js] +[test_promises_actor_attach.js] +[test_promises_actor_exist.js] +[test_promises_actor_list_promises.js] +[test_promises_actor_onnewpromise.js] +[test_promises_actor_onpromisesettled.js] +[test_promises_client_getdependentpromises.js] +[test_promises_object_creationtimestamp.js] +[test_promises_object_timetosettle-01.js] +[test_promises_object_timetosettle-02.js] +[test_protocol_abort.js] +[test_protocol_async.js] +[test_protocol_children.js] +[test_protocol_formtype.js] +[test_protocol_longstring.js] +[test_protocol_simple.js] +[test_protocol_stack.js] +[test_protocol_unregister.js] +[test_breakpoint-01.js] +[test_register_actor.js] +[test_breakpoint-02.js] +[test_breakpoint-03.js] +[test_breakpoint-04.js] +[test_breakpoint-05.js] +[test_breakpoint-06.js] +[test_breakpoint-07.js] +[test_breakpoint-08.js] +[test_breakpoint-09.js] +[test_breakpoint-10.js] +[test_breakpoint-11.js] +[test_breakpoint-12.js] +[test_breakpoint-13.js] +[test_breakpoint-14.js] +[test_breakpoint-15.js] +[test_breakpoint-16.js] +[test_breakpoint-17.js] +[test_breakpoint-18.js] +[test_breakpoint-19.js] +skip-if = true +reason = bug 1104838 +[test_breakpoint-20.js] +[test_breakpoint-21.js] +[test_conditional_breakpoint-01.js] +[test_conditional_breakpoint-02.js] +[test_conditional_breakpoint-03.js] +[test_eventlooplag_actor.js] +skip-if = true +reason = only ran on B2G +[test_listsources-01.js] +[test_listsources-02.js] +[test_listsources-03.js] +[test_listsources-04.js] +[test_new_source-01.js] +[test_sourcemaps-01.js] +[test_sourcemaps-02.js] +[test_sourcemaps-03.js] +[test_sourcemaps-04.js] +[test_sourcemaps-05.js] +[test_sourcemaps-06.js] +[test_sourcemaps-07.js] +[test_sourcemaps-08.js] +[test_sourcemaps-09.js] +[test_sourcemaps-10.js] +[test_sourcemaps-11.js] +[test_sourcemaps-12.js] +[test_sourcemaps-13.js] +[test_sourcemaps-16.js] +[test_sourcemaps-17.js] +[test_objectgrips-01.js] +[test_objectgrips-02.js] +[test_objectgrips-03.js] +[test_objectgrips-04.js] +[test_objectgrips-05.js] +[test_objectgrips-06.js] +[test_objectgrips-07.js] +[test_objectgrips-08.js] +[test_objectgrips-09.js] +[test_objectgrips-10.js] +[test_objectgrips-11.js] +[test_objectgrips-12.js] +[test_objectgrips-13.js] +[test_promise_state-01.js] +[test_promise_state-02.js] +[test_promise_state-03.js] +[test_interrupt.js] +[test_stepping-01.js] +[test_stepping-02.js] +[test_stepping-03.js] +[test_stepping-04.js] +[test_stepping-05.js] +[test_stepping-06.js] +[test_stepping-07.js] +[test_framebindings-01.js] +[test_framebindings-02.js] +[test_framebindings-03.js] +[test_framebindings-04.js] +[test_framebindings-05.js] +[test_framebindings-06.js] +[test_framebindings-07.js] +[test_pause_exceptions-01.js] +[test_pause_exceptions-02.js] +[test_longstringactor.js] +[test_longstringgrips-01.js] +[test_longstringgrips-02.js] +[test_source-01.js] +[test_breakpoint-actor-map.js] +[test_profiler_activation-01.js] +[test_profiler_activation-02.js] +[test_profiler_close.js] +[test_profiler_data.js] +[test_profiler_events-01.js] +[test_profiler_events-02.js] +[test_profiler_getbufferinfo.js] +[test_profiler_getfeatures.js] +[test_profiler_getsharedlibraryinformation.js] +[test_unsafeDereference.js] +[test_add_actors.js] +[test_ignore_caught_exceptions.js] +[test_ignore_no_interface_exceptions.js] +[test_requestTypes.js] +reason = bug 937197 +[test_layout-reflows-observer.js] +[test_protocolSpec.js] +[test_registerClient.js] +[test_client_request.js] +[test_monitor_actor.js] +[test_symbols-01.js] +[test_symbols-02.js] +[test_get-executable-lines.js] +[test_get-executable-lines-source-map.js] +[test_xpcshell_debugging.js] +support-files = xpcshell_debugging_script.js +[test_setBreakpoint-on-column.js] +[test_setBreakpoint-on-column-in-gcd-script.js] +[test_setBreakpoint-on-column-with-no-offsets-at-end-of-line.js] +[test_setBreakpoint-on-line.js] +[test_setBreakpoint-on-line-in-gcd-script.js] +[test_setBreakpoint-on-line-with-multiple-offsets.js] +[test_setBreakpoint-on-line-with-multiple-statements.js] +[test_setBreakpoint-on-line-with-no-offsets.js] +[test_setBreakpoint-on-line-with-no-offsets-in-gcd-script.js] +[test_safe-getter.js] +[test_client_close.js] diff --git a/devtools/server/tests/unit/xpcshell_debugging_script.js b/devtools/server/tests/unit/xpcshell_debugging_script.js new file mode 100644 index 000000000..de2870e96 --- /dev/null +++ b/devtools/server/tests/unit/xpcshell_debugging_script.js @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This is a file that test_xpcshell_debugging.js debugs. + +// We should hit this dump as it is the first debuggable line +dump("hello from the debugee!\n"); + +debugger; // and why not check we hit this!? diff --git a/devtools/server/websocket-server.js b/devtools/server/websocket-server.js new file mode 100644 index 000000000..6e8a80fec --- /dev/null +++ b/devtools/server/websocket-server.js @@ -0,0 +1,221 @@ +/* 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 { Cc, CC } = require("chrome"); +const Promise = require("promise"); +const { Task } = require("devtools/shared/task"); +const { executeSoon } = require("devtools/shared/DevToolsUtils"); +const { delimitedRead } = require("devtools/shared/transport/stream-utils"); +const CryptoHash = CC("@mozilla.org/security/hash;1", "nsICryptoHash", "initWithString"); +const threadManager = Cc["@mozilla.org/thread-manager;1"].getService(); + +// Limit the header size to put an upper bound on allocated memory +const HEADER_MAX_LEN = 8000; + +/** + * Read a line from async input stream and return promise that resolves to the line once + * it has been read. If the line is longer than HEADER_MAX_LEN, will throw error. + */ +function readLine(input) { + return new Promise((resolve, reject) => { + let line = ""; + let wait = () => { + input.asyncWait(stream => { + try { + let amountToRead = HEADER_MAX_LEN - line.length; + line += delimitedRead(input, "\n", amountToRead); + + if (line.endsWith("\n")) { + resolve(line.trimRight()); + return; + } + + if (line.length >= HEADER_MAX_LEN) { + throw new Error( + `Failed to read HTTP header longer than ${HEADER_MAX_LEN} bytes`); + } + + wait(); + } catch (ex) { + reject(ex); + } + }, 0, 0, threadManager.currentThread); + }; + + wait(); + }); +} + +/** + * Write a string of bytes to async output stream and return promise that resolves once + * all data has been written. Doesn't do any utf-16/utf-8 conversion - the string is + * treated as an array of bytes. + */ +function writeString(output, data) { + return new Promise((resolve, reject) => { + let wait = () => { + if (data.length === 0) { + resolve(); + return; + } + + output.asyncWait(stream => { + try { + let written = output.write(data, data.length); + data = data.slice(written); + wait(); + } catch (ex) { + reject(ex); + } + }, 0, 0, threadManager.currentThread); + }; + + wait(); + }); +} + +/** + * Read HTTP request from async input stream. + * @return Request line (string) and Map of header names and values. + */ +const readHttpRequest = Task.async(function* (input) { + let requestLine = ""; + let headers = new Map(); + + while (true) { + let line = yield readLine(input); + if (line.length == 0) { + break; + } + + if (!requestLine) { + requestLine = line; + } else { + let colon = line.indexOf(":"); + if (colon == -1) { + throw new Error(`Malformed HTTP header: ${line}`); + } + + let name = line.slice(0, colon).toLowerCase(); + let value = line.slice(colon + 1).trim(); + headers.set(name, value); + } + } + + return { requestLine, headers }; +}); + +/** + * Write HTTP response (array of strings) to async output stream. + */ +function writeHttpResponse(output, response) { + let responseString = response.join("\r\n") + "\r\n\r\n"; + return writeString(output, responseString); +} + +/** + * Process the WebSocket handshake headers and return the key to be sent in + * Sec-WebSocket-Accept response header. + */ +function processRequest({ requestLine, headers }) { + let [ method, path ] = requestLine.split(" "); + if (method !== "GET") { + throw new Error("The handshake request must use GET method"); + } + + if (path !== "/") { + throw new Error("The handshake request has unknown path"); + } + + let upgrade = headers.get("upgrade"); + if (!upgrade || upgrade !== "websocket") { + throw new Error("The handshake request has incorrect Upgrade header"); + } + + let connection = headers.get("connection"); + if (!connection || !connection.split(",").map(t => t.trim()).includes("Upgrade")) { + throw new Error("The handshake request has incorrect Connection header"); + } + + let version = headers.get("sec-websocket-version"); + if (!version || version !== "13") { + throw new Error("The handshake request must have Sec-WebSocket-Version: 13"); + } + + // Compute the accept key + let key = headers.get("sec-websocket-key"); + if (!key) { + throw new Error("The handshake request must have a Sec-WebSocket-Key header"); + } + + return { acceptKey: computeKey(key) }; +} + +function computeKey(key) { + let str = key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + + let data = Array.from(str, ch => ch.charCodeAt(0)); + let hash = new CryptoHash("sha1"); + hash.update(data, data.length); + return hash.finish(true); +} + +/** + * Perform the server part of a WebSocket opening handshake on an incoming connection. + */ +const serverHandshake = Task.async(function* (input, output) { + // Read the request + let request = yield readHttpRequest(input); + + try { + // Check and extract info from the request + let { acceptKey } = processRequest(request); + + // Send response headers + yield writeHttpResponse(output, [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${acceptKey}`, + ]); + } catch (error) { + // Send error response in case of error + yield writeHttpResponse(output, [ "HTTP/1.1 400 Bad Request" ]); + throw error; + } +}); + +/** + * Accept an incoming WebSocket server connection. + * Takes an established nsISocketTransport in the parameters. + * Performs the WebSocket handshake and waits for the WebSocket to open. + * Returns Promise with a WebSocket ready to send and receive messages. + */ +const accept = Task.async(function* (transport, input, output) { + yield serverHandshake(input, output); + + let transportProvider = { + setListener(upgradeListener) { + // The onTransportAvailable callback shouldn't be called synchronously. + executeSoon(() => { + upgradeListener.onTransportAvailable(transport, input, output); + }); + } + }; + + return new Promise((resolve, reject) => { + let socket = WebSocket.createServerWebSocket(null, [], transportProvider, ""); + socket.addEventListener("close", () => { + input.close(); + output.close(); + }); + + socket.onopen = () => resolve(socket); + socket.onerror = err => reject(err); + }); +}); + +exports.accept = accept; diff --git a/devtools/server/worker.js b/devtools/server/worker.js new file mode 100644 index 000000000..e9a1afb8e --- /dev/null +++ b/devtools/server/worker.js @@ -0,0 +1,110 @@ +"use strict"; + +// This function is used to do remote procedure calls from the worker to the +// main thread. It is exposed as a built-in global to every module by the +// worker loader. To make sure the worker loader can access it, it needs to be +// defined before loading the worker loader script below. +this.rpc = function (method, ...params) { + let id = nextId++; + + postMessage(JSON.stringify({ + type: "rpc", + method: method, + params: params, + id: id + })); + + let deferred = Promise.defer(); + rpcDeferreds[id] = deferred; + return deferred.promise; +}; + +loadSubScript("resource://devtools/shared/worker/loader.js"); + +var Promise = worker.require("promise"); +var { ActorPool } = worker.require("devtools/server/actors/common"); +var { ThreadActor } = worker.require("devtools/server/actors/script"); +var { WebConsoleActor } = worker.require("devtools/server/actors/webconsole"); +var { TabSources } = worker.require("devtools/server/actors/utils/TabSources"); +var makeDebugger = worker.require("devtools/server/actors/utils/make-debugger"); +var { DebuggerServer } = worker.require("devtools/server/main"); + +DebuggerServer.init(); +DebuggerServer.createRootActor = function () { + throw new Error("Should never get here!"); +}; + +var connections = Object.create(null); +var nextId = 0; +var rpcDeferreds = []; + +this.addEventListener("message", function (event) { + let packet = JSON.parse(event.data); + switch (packet.type) { + case "connect": + // Step 3: Create a connection to the parent. + let connection = DebuggerServer.connectToParent(packet.id, this); + connections[packet.id] = { + connection : connection, + rpcs: [] + }; + + // Step 4: Create a thread actor for the connection to the parent. + let pool = new ActorPool(connection); + connection.addActorPool(pool); + + let sources = null; + + let parent = { + actorID: packet.id, + + makeDebugger: makeDebugger.bind(null, { + findDebuggees: () => { + return [this.global]; + }, + + shouldAddNewGlobalAsDebuggee: () => { + return true; + }, + }), + + get sources() { + if (sources === null) { + sources = new TabSources(threadActor); + } + return sources; + }, + + window: global + }; + + let threadActor = new ThreadActor(parent, global); + pool.addActor(threadActor); + + let consoleActor = new WebConsoleActor(connection, parent); + pool.addActor(consoleActor); + + // Step 5: Send a response packet to the parent to notify + // it that a connection has been established. + postMessage(JSON.stringify({ + type: "connected", + id: packet.id, + threadActor: threadActor.actorID, + consoleActor: consoleActor.actorID, + })); + break; + + case "disconnect": + connections[packet.id].connection.close(); + break; + + case "rpc": + let deferred = rpcDeferreds[packet.id]; + delete rpcDeferreds[packet.id]; + if (packet.error) { + deferred.reject(packet.error); + } + deferred.resolve(packet.result); + break; + } +}); |