summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/utils
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /devtools/server/actors/utils
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/actors/utils')
-rw-r--r--devtools/server/actors/utils/accessibility.js125
-rw-r--r--devtools/server/actors/utils/actor-registry.js441
-rw-r--r--devtools/server/actors/utils/breakpoint-actor-map.js72
-rw-r--r--devtools/server/actors/utils/capture-screenshot.js212
-rw-r--r--devtools/server/actors/utils/css-grid-utils.js77
-rw-r--r--devtools/server/actors/utils/dbg-source.js97
-rw-r--r--devtools/server/actors/utils/event-breakpoints.js478
-rw-r--r--devtools/server/actors/utils/event-loop.js201
-rw-r--r--devtools/server/actors/utils/inactive-property-helper.js1072
-rw-r--r--devtools/server/actors/utils/logEvent.js97
-rw-r--r--devtools/server/actors/utils/make-debugger.js110
-rw-r--r--devtools/server/actors/utils/moz.build28
-rw-r--r--devtools/server/actors/utils/shapes-utils.js149
-rw-r--r--devtools/server/actors/utils/source-map-utils.js53
-rw-r--r--devtools/server/actors/utils/source-url.js38
-rw-r--r--devtools/server/actors/utils/sources-manager.js503
-rw-r--r--devtools/server/actors/utils/stack.js183
-rw-r--r--devtools/server/actors/utils/style-utils.js185
-rw-r--r--devtools/server/actors/utils/track-change-emitter.js26
-rw-r--r--devtools/server/actors/utils/walker-search.js313
-rw-r--r--devtools/server/actors/utils/watchpoint-map.js163
21 files changed, 4623 insertions, 0 deletions
diff --git a/devtools/server/actors/utils/accessibility.js b/devtools/server/actors/utils/accessibility.js
new file mode 100644
index 0000000000..b246988c25
--- /dev/null
+++ b/devtools/server/actors/utils/accessibility.js
@@ -0,0 +1,125 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+loader.lazyRequireGetter(this, "Ci", "chrome", true);
+loader.lazyRequireGetter(this, "Services");
+loader.lazyRequireGetter(
+ this,
+ ["loadSheet", "removeSheet"],
+ "devtools/shared/layout/utils",
+ true
+);
+
+// Highlighter style used for preventing transitions and applying transparency
+// when calculating colour contrast.
+const HIGHLIGHTER_STYLES_SHEET = `data:text/css;charset=utf-8,
+* {
+ transition: none !important;
+}
+
+:-moz-devtools-highlighted {
+ color: transparent !important;
+ text-shadow: none !important;
+}`;
+
+/**
+ * Helper function that determines if nsIAccessible object is in defunct state.
+ *
+ * @param {nsIAccessible} accessible
+ * object to be tested.
+ * @return {Boolean}
+ * True if accessible object is defunct, false otherwise.
+ */
+function isDefunct(accessible) {
+ // If accessibility is disabled, safely assume that the accessible object is
+ // now dead.
+ if (!Services.appinfo.accessibilityEnabled) {
+ return true;
+ }
+
+ let defunct = false;
+
+ try {
+ const extraState = {};
+ accessible.getState({}, extraState);
+ // extraState.value is a bitmask. We are applying bitwise AND to mask out
+ // irrelevant states.
+ defunct = !!(extraState.value & Ci.nsIAccessibleStates.EXT_STATE_DEFUNCT);
+ } catch (e) {
+ defunct = true;
+ }
+
+ return defunct;
+}
+
+/**
+ * Load highlighter style sheet used for preventing transitions and
+ * applying transparency when calculating colour contrast.
+ *
+ * @param {Window} win
+ * Window where highlighting happens.
+ */
+function loadSheetForBackgroundCalculation(win) {
+ loadSheet(win, HIGHLIGHTER_STYLES_SHEET);
+}
+
+/**
+ * Unload highlighter style sheet used for preventing transitions
+ * and applying transparency when calculating colour contrast.
+ *
+ * @param {Window} win
+ * Window where highlighting was happenning.
+ */
+function removeSheetForBackgroundCalculation(win) {
+ removeSheet(win, HIGHLIGHTER_STYLES_SHEET);
+}
+
+/**
+ * Helper function that determines if web render is enabled.
+ *
+ * @param {Window} win
+ * Window to be tested.
+ * @return {Boolean}
+ * True if web render is enabled, false otherwise.
+ */
+function isWebRenderEnabled(win) {
+ try {
+ return win.windowUtils && win.windowUtils.layerManagerType === "WebRender";
+ } catch (e) {
+ // Sometimes nsIDOMWindowUtils::layerManagerType fails unexpectedly (see bug
+ // 1596428).
+ console.warn(e);
+ }
+
+ return false;
+}
+
+/**
+ * Get role attribute for an accessible object if specified for its
+ * corresponding DOMNode.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible for which to determine its role attribute value.
+ *
+ * @returns {null|String}
+ * Role attribute value if specified.
+ */
+function getAriaRoles(accessible) {
+ try {
+ return accessible.attributes.getStringProperty("xml-roles");
+ } catch (e) {
+ // No xml-roles. nsPersistentProperties throws if the attribute for a key
+ // is not found.
+ }
+
+ return null;
+}
+
+exports.getAriaRoles = getAriaRoles;
+exports.isDefunct = isDefunct;
+exports.isWebRenderEnabled = isWebRenderEnabled;
+exports.loadSheetForBackgroundCalculation = loadSheetForBackgroundCalculation;
+exports.removeSheetForBackgroundCalculation = removeSheetForBackgroundCalculation;
diff --git a/devtools/server/actors/utils/actor-registry.js b/devtools/server/actors/utils/actor-registry.js
new file mode 100644
index 0000000000..3b4a228f31
--- /dev/null
+++ b/devtools/server/actors/utils/actor-registry.js
@@ -0,0 +1,441 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 } = require("chrome");
+var gRegisteredModules = Object.create(null);
+
+const ActorRegistry = {
+ // Map of global actor names to actor constructors.
+ globalActorFactories: {},
+ // Map of target-scoped actor names to actor constructors.
+ targetScopedActorFactories: {},
+ init(connections) {
+ this._connections = connections;
+ },
+
+ /**
+ * Register a CommonJS module with the devtools server.
+ * @param id string
+ * The ID of a CommonJS module.
+ * 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
+ * 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 Pool.manage)
+ * - 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.
+ * - "target"
+ * registers a target-scoped actor instance, if true.
+ * A new actor will be created for each target, such as a tab.
+ */
+ registerModule(id, options) {
+ if (id in gRegisteredModules) {
+ return;
+ }
+
+ if (!options) {
+ throw new Error(
+ "ActorRegistry.registerModule requires an options argument"
+ );
+ }
+ const { 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) && !("target" in type)) {
+ throw new Error(
+ `Lazy actor definition for '${id}' requires a dictionary ` +
+ `'type' option whose attributes can be 'global' or 'target'.`
+ );
+ }
+ const name = prefix + "Actor";
+ const mod = {
+ id,
+ prefix,
+ constructorName: constructor,
+ type,
+ globalActor: type.global,
+ targetScopedActor: type.target,
+ };
+ gRegisteredModules[id] = mod;
+ if (mod.targetScopedActor) {
+ this.addTargetScopedActor(mod, name);
+ }
+ if (mod.globalActor) {
+ this.addGlobalActor(mod, name);
+ }
+ },
+
+ /**
+ * Unregister a previously-loaded CommonJS module from the devtools server.
+ */
+ unregisterModule(id) {
+ const mod = gRegisteredModules[id];
+ if (!mod) {
+ throw new Error(
+ "Tried to unregister a module that was not previously registered."
+ );
+ }
+
+ // Lazy actors
+ if (mod.targetScopedActor) {
+ this.removeTargetScopedActor(mod);
+ }
+ if (mod.globalActor) {
+ this.removeGlobalActor(mod);
+ }
+
+ 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.
+ */
+ addBrowserActors() {
+ this.registerModule("devtools/server/actors/preference", {
+ prefix: "preference",
+ constructor: "PreferenceActor",
+ type: { global: true },
+ });
+ this.registerModule("devtools/server/actors/addon/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/heap-snapshot-file", {
+ prefix: "heapSnapshotFile",
+ constructor: "HeapSnapshotFileActor",
+ type: { global: true },
+ });
+ // Always register this as a global module, even while there is a pref turning
+ // on and off the other performance actor. This actor shouldn't conflict with
+ // the other one. These are also lazily loaded so there shouldn't be a performance
+ // impact.
+ this.registerModule("devtools/server/actors/perf", {
+ prefix: "perf",
+ constructor: "PerfActor",
+ type: { global: true },
+ });
+ /**
+ * Always register parent accessibility actor as a global module. This
+ * actor is responsible for all top level accessibility actor functionality
+ * that relies on the parent process.
+ */
+ this.registerModule(
+ "devtools/server/actors/accessibility/parent-accessibility",
+ {
+ prefix: "parentAccessibility",
+ constructor: "ParentAccessibilityActor",
+ type: { global: true },
+ }
+ );
+ },
+
+ /**
+ * Install target-scoped actors.
+ */
+ addTargetScopedActors() {
+ this.registerModule("devtools/server/actors/webconsole", {
+ prefix: "console",
+ constructor: "WebConsoleActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/inspector/inspector", {
+ prefix: "inspector",
+ constructor: "InspectorActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/style-sheets", {
+ prefix: "styleSheets",
+ constructor: "StyleSheetsActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/storage", {
+ prefix: "storage",
+ constructor: "StorageActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/memory", {
+ prefix: "memory",
+ constructor: "MemoryActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/framerate", {
+ prefix: "framerate",
+ constructor: "FramerateActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/reflow", {
+ prefix: "reflow",
+ constructor: "ReflowActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/css-properties", {
+ prefix: "cssProperties",
+ constructor: "CssPropertiesActor",
+ type: { target: true },
+ });
+ if ("nsIProfiler" in Ci) {
+ this.registerModule("devtools/server/actors/performance", {
+ prefix: "performance",
+ constructor: "PerformanceActor",
+ type: { target: true },
+ });
+ }
+ this.registerModule("devtools/server/actors/animation", {
+ prefix: "animations",
+ constructor: "AnimationsActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/emulation/responsive", {
+ prefix: "responsive",
+ constructor: "ResponsiveActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/emulation/content-viewer", {
+ prefix: "contentViewer",
+ constructor: "ContentViewerActor",
+ type: { target: true },
+ });
+ this.registerModule(
+ "devtools/server/actors/addon/webextension-inspected-window",
+ {
+ prefix: "webExtensionInspectedWindow",
+ constructor: "WebExtensionInspectedWindowActor",
+ type: { target: true },
+ }
+ );
+ this.registerModule("devtools/server/actors/accessibility/accessibility", {
+ prefix: "accessibility",
+ constructor: "AccessibilityActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/screenshot", {
+ prefix: "screenshot",
+ constructor: "ScreenshotActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/changes", {
+ prefix: "changes",
+ constructor: "ChangesActor",
+ type: { target: true },
+ });
+ this.registerModule(
+ "devtools/server/actors/network-monitor/websocket-actor",
+ {
+ prefix: "webSocket",
+ constructor: "WebSocketActor",
+ type: { target: true },
+ }
+ );
+ this.registerModule(
+ "devtools/server/actors/network-monitor/eventsource-actor",
+ {
+ prefix: "eventSource",
+ constructor: "EventSourceActor",
+ type: { target: true },
+ }
+ );
+ this.registerModule("devtools/server/actors/manifest", {
+ prefix: "manifest",
+ constructor: "ManifestActor",
+ type: { target: true },
+ });
+ this.registerModule(
+ "devtools/server/actors/network-monitor/network-content",
+ {
+ prefix: "networkContent",
+ constructor: "NetworkContentActor",
+ type: { target: true },
+ }
+ );
+ },
+
+ /**
+ * Registers handlers for new target-scoped request types defined dynamically.
+ *
+ * Note that the name 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 options object
+ * - constructorName: (required)
+ * name of actor constructor, which is also used when removing the actor.
+ * One of the following:
+ * - id:
+ * module ID that contains the actor
+ * - constructorFun:
+ * a function to construct the actor
+ * @param name string
+ * The name of the new request type.
+ */
+ addTargetScopedActor(options, name) {
+ if (!name) {
+ throw Error("addTargetScopedActor requires the `name` argument");
+ }
+ if (["title", "url", "actor"].includes(name)) {
+ throw Error(name + " is not allowed");
+ }
+ if (this.targetScopedActorFactories.hasOwnProperty(name)) {
+ throw Error(name + " already exists");
+ }
+ this.targetScopedActorFactories[name] = { options, name };
+ },
+
+ /**
+ * Unregisters the handler for the specified target-scoped request type.
+ *
+ * When unregistering an existing target-scoped actor, we remove the actor factory as
+ * well as all existing instances of the actor.
+ *
+ * @param actor object, string
+ * In case of object:
+ * The `actor` object being given to related addTargetScopedActor call.
+ * In case of string:
+ * The `name` string being given to related addTargetScopedActor call.
+ */
+ removeTargetScopedActor(actorOrName) {
+ let name;
+ if (typeof actorOrName == "string") {
+ name = actorOrName;
+ } else {
+ const actor = actorOrName;
+ for (const factoryName in this.targetScopedActorFactories) {
+ const handler = this.targetScopedActorFactories[factoryName];
+ if (
+ handler.options.constructorName == actor.name ||
+ handler.options.id == actor.id
+ ) {
+ name = factoryName;
+ break;
+ }
+ }
+ }
+ if (!name) {
+ return;
+ }
+ delete this.targetScopedActorFactories[name];
+ for (const connID of Object.getOwnPropertyNames(this._connections)) {
+ // DevToolsServerConnection 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.
+ *
+ * Note that the name 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 options object
+ * - constructorName: (required)
+ * name of actor constructor, which is also used when removing the actor.
+ * One of the following:
+ * - id:
+ * module ID that contains the actor
+ * - constructorFun:
+ * a function to construct the actor
+ * @param name string
+ * The name of the new request type.
+ */
+ addGlobalActor(options, name) {
+ if (!name) {
+ throw Error("addGlobalActor requires the `name` argument");
+ }
+ if (["from", "tabs", "selected"].includes(name)) {
+ throw Error(name + " is not allowed");
+ }
+ if (this.globalActorFactories.hasOwnProperty(name)) {
+ throw Error(name + " already exists");
+ }
+ this.globalActorFactories[name] = { options, name };
+ },
+
+ /**
+ * Unregisters the handler for the specified browser-scoped request type.
+ *
+ * When unregistering an existing global actor, we remove the actor factory as well as
+ * all existing instances of the actor.
+ *
+ * @param actor object, string
+ * In case of object:
+ * The `actor` object being given to related addGlobalActor call.
+ * In case of string:
+ * The `name` string being given to related addGlobalActor call.
+ */
+ removeGlobalActor(actorOrName) {
+ let name;
+ if (typeof actorOrName == "string") {
+ name = actorOrName;
+ } else {
+ const actor = actorOrName;
+ for (const factoryName in this.globalActorFactories) {
+ const handler = this.globalActorFactories[factoryName];
+ if (
+ handler.options.constructorName == actor.name ||
+ handler.options.id == actor.id
+ ) {
+ name = factoryName;
+ break;
+ }
+ }
+ }
+ if (!name) {
+ return;
+ }
+ delete this.globalActorFactories[name];
+ for (const connID of Object.getOwnPropertyNames(this._connections)) {
+ // DevToolsServerConnection in child process don't have rootActor
+ if (this._connections[connID].rootActor) {
+ this._connections[connID].rootActor.removeActorByName(name);
+ }
+ }
+ },
+
+ destroy() {
+ for (const id of Object.getOwnPropertyNames(gRegisteredModules)) {
+ this.unregisterModule(id);
+ }
+ gRegisteredModules = Object.create(null);
+
+ this.globalActorFactories = {};
+ this.targetScopedActorFactories = {};
+ },
+};
+
+exports.ActorRegistry = ActorRegistry;
diff --git a/devtools/server/actors/utils/breakpoint-actor-map.js b/devtools/server/actors/utils/breakpoint-actor-map.js
new file mode 100644
index 0000000000..acf8d5bf98
--- /dev/null
+++ b/devtools/server/actors/utils/breakpoint-actor-map.js
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { BreakpointActor } = require("devtools/server/actors/breakpoint");
+
+/**
+ * A BreakpointActorMap is a map from locations to instances of BreakpointActor.
+ */
+function BreakpointActorMap(threadActor) {
+ this._threadActor = threadActor;
+ this._actors = {};
+}
+
+BreakpointActorMap.prototype = {
+ // Get the key in the _actors table for a given breakpoint location.
+ // See also duplicate code in commands.js :(
+ _locationKey(location) {
+ const { sourceUrl, sourceId, line, column } = location;
+ return `${sourceUrl}:${sourceId}:${line}:${column}`;
+ },
+
+ /**
+ * Return all BreakpointActors in this BreakpointActorMap.
+ */
+ findActors() {
+ return Object.values(this._actors);
+ },
+
+ listKeys() {
+ return Object.keys(this._actors);
+ },
+
+ /**
+ * Return the BreakpointActor at the given location in this
+ * BreakpointActorMap.
+ *
+ * @param BreakpointLocation location
+ * The location for which the BreakpointActor should be returned.
+ *
+ * @returns BreakpointActor actor
+ * The BreakpointActor at the given location.
+ */
+ getOrCreateBreakpointActor(location) {
+ const key = this._locationKey(location);
+ if (!this._actors[key]) {
+ this._actors[key] = new BreakpointActor(this._threadActor, location);
+ }
+ return this._actors[key];
+ },
+
+ get(location) {
+ const key = this._locationKey(location);
+ return this._actors[key];
+ },
+
+ /**
+ * Delete the BreakpointActor from the given location in this
+ * BreakpointActorMap.
+ *
+ * @param BreakpointLocation location
+ * The location from which the BreakpointActor should be deleted.
+ */
+ deleteActor(location) {
+ const key = this._locationKey(location);
+ delete this._actors[key];
+ },
+};
+
+exports.BreakpointActorMap = BreakpointActorMap;
diff --git a/devtools/server/actors/utils/capture-screenshot.js b/devtools/server/actors/utils/capture-screenshot.js
new file mode 100644
index 0000000000..11deebc4d5
--- /dev/null
+++ b/devtools/server/actors/utils/capture-screenshot.js
@@ -0,0 +1,212 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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, Cc, Ci } = require("chrome");
+const Services = require("Services");
+const { LocalizationHelper } = require("devtools/shared/l10n");
+
+loader.lazyRequireGetter(this, "getRect", "devtools/shared/layout/utils", true);
+
+const CONTAINER_FLASHING_DURATION = 500;
+const STRINGS_URI = "devtools/shared/locales/screenshot.properties";
+const L10N = new LocalizationHelper(STRINGS_URI);
+
+// These values are used to truncate the resulting image if the captured area is bigger.
+// This is to avoid failing to produce a screenshot at all.
+// It is recommended to keep these values in sync with the corresponding screenshots addon
+// values in /browser/extensions/screenshots/build/buildSettings.js
+const MAX_IMAGE_WIDTH = 10000;
+const MAX_IMAGE_HEIGHT = 10000;
+
+/**
+ * This function is called to simulate camera effects
+ * @param object document
+ * The target document.
+ */
+function simulateCameraFlash(document) {
+ const window = document.defaultView;
+ const frames = Cu.cloneInto({ opacity: [0, 1] }, window);
+ document.documentElement.animate(frames, CONTAINER_FLASHING_DURATION);
+}
+
+/**
+ * This function simply handles the --delay argument before calling
+ * createScreenshotData
+ */
+function captureScreenshot(args, document) {
+ if (args.help) {
+ return null;
+ }
+ if (args.delay > 0) {
+ return new Promise((resolve, reject) => {
+ document.defaultView.setTimeout(() => {
+ createScreenshotDataURL(document, args).then(resolve, reject);
+ }, args.delay * 1000);
+ });
+ }
+ return createScreenshotDataURL(document, args);
+}
+
+exports.captureScreenshot = captureScreenshot;
+
+/**
+ * This does the dirty work of creating a base64 string out of an
+ * area of the browser window
+ */
+function createScreenshotDataURL(document, args) {
+ let window = document.defaultView;
+ let left = 0;
+ let top = 0;
+ let width;
+ let height;
+ const currentX = window.scrollX;
+ const currentY = window.scrollY;
+
+ let filename = getFilename(args.filename);
+
+ if (args.fullpage) {
+ // Bug 961832: Screenshot shows fixed position element in wrong
+ // position if we don't scroll to top
+ window.scrollTo(0, 0);
+ width = window.innerWidth + window.scrollMaxX - window.scrollMinX;
+ height = window.innerHeight + window.scrollMaxY - window.scrollMinY;
+ filename = filename.replace(".png", "-fullpage.png");
+ } else if (args.rawNode) {
+ window = args.rawNode.ownerGlobal;
+ ({ top, left, width, height } = getRect(window, args.rawNode, window));
+ } else if (args.selector) {
+ const node = window.document.querySelector(args.selector);
+ ({ top, left, width, height } = getRect(window, node, window));
+ } else {
+ left = window.scrollX;
+ top = window.scrollY;
+ width = window.innerWidth;
+ height = window.innerHeight;
+ }
+
+ // Only adjust for scrollbars when considering the full window
+ if (args.fullpage) {
+ const winUtils = window.windowUtils;
+ const scrollbarHeight = {};
+ const scrollbarWidth = {};
+ winUtils.getScrollbarSize(false, scrollbarWidth, scrollbarHeight);
+ width -= scrollbarWidth.value;
+ height -= scrollbarHeight.value;
+ }
+
+ // Truncate the width and height if necessary.
+ if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT) {
+ width = Math.min(width, MAX_IMAGE_WIDTH);
+ height = Math.min(height, MAX_IMAGE_HEIGHT);
+ logWarningInPage(
+ L10N.getFormatStr("screenshotTruncationWarning", width, height),
+ window
+ );
+ }
+
+ const ratio = args.dpr ? args.dpr : window.devicePixelRatio;
+
+ const canvas = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ const ctx = canvas.getContext("2d");
+
+ const drawToCanvas = actualRatio => {
+ // Even after decreasing width, height and ratio, there may still be cases where the
+ // hardware fails at creating the image. Let's catch this so we can at least show an
+ // error message to the user.
+ try {
+ canvas.width = width * actualRatio;
+ canvas.height = height * actualRatio;
+ ctx.scale(actualRatio, actualRatio);
+ ctx.drawWindow(window, left, top, width, height, "#fff");
+ return canvas.toDataURL("image/png", "");
+ } catch (e) {
+ return null;
+ }
+ };
+
+ let data = drawToCanvas(ratio);
+ if (!data && ratio > 1.0) {
+ // If the user provided DPR or the window.devicePixelRatio was higher than 1,
+ // try again with a reduced ratio.
+ logWarningInPage(L10N.getStr("screenshotDPRDecreasedWarning"), window);
+ data = drawToCanvas(1.0);
+ }
+ if (!data) {
+ logErrorInPage(L10N.getStr("screenshotRenderingError"), window);
+ }
+
+ // See comment above on bug 961832
+ if (args.fullpage) {
+ window.scrollTo(currentX, currentY);
+ }
+
+ if (data) {
+ simulateCameraFlash(document);
+ }
+
+ return Promise.resolve({
+ destinations: [],
+ data,
+ height,
+ width,
+ filename,
+ });
+}
+
+/**
+ * We may have a filename specified in args, or we might have to generate
+ * one.
+ */
+function getFilename(defaultName) {
+ // Create a name for the file if not present
+ if (defaultName) {
+ return defaultName;
+ }
+
+ const date = new Date();
+ let dateString =
+ date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate();
+ dateString = dateString
+ .split("-")
+ .map(function(part) {
+ if (part.length == 1) {
+ part = "0" + part;
+ }
+ return part;
+ })
+ .join("-");
+
+ const timeString = date
+ .toTimeString()
+ .replace(/:/g, ".")
+ .split(" ")[0];
+ return (
+ L10N.getFormatStr("screenshotGeneratedFilename", dateString, timeString) +
+ ".png"
+ );
+}
+
+function logInPage(text, flags, window) {
+ const scriptError = Cc["@mozilla.org/scripterror;1"].createInstance(
+ Ci.nsIScriptError
+ );
+ scriptError.initWithWindowID(
+ text,
+ null,
+ null,
+ 0,
+ 0,
+ flags,
+ "screenshot",
+ window.windowGlobalChild.innerWindowId
+ );
+ Services.console.logMessage(scriptError);
+}
+
+const logErrorInPage = (text, window) => logInPage(text, 0, window);
+const logWarningInPage = (text, window) => logInPage(text, 1, window);
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 0000000000..0211963708
--- /dev/null
+++ b/devtools/server/actors/utils/css-grid-utils.js
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 {
+ areas: getStringifiableAreas(fragment.areas),
+ cols: getStringifiableDimension(fragment.cols),
+ rows: getStringifiableDimension(fragment.rows),
+ };
+}
+
+function getStringifiableAreas(areas) {
+ return [...areas].map(getStringifiableArea);
+}
+
+function getStringifiableDimension(dimension) {
+ return {
+ lines: [...dimension.lines].map(getStringifiableLine),
+ tracks: [...dimension.tracks].map(getStringifiableTrack),
+ };
+}
+
+function getStringifiableArea({
+ columnEnd,
+ columnStart,
+ name,
+ rowEnd,
+ rowStart,
+ type,
+}) {
+ return { columnEnd, columnStart, name, rowEnd, rowStart, type };
+}
+
+function getStringifiableLine({ breadth, names, number, start, type }) {
+ return { breadth, names, number, start, type };
+}
+
+function getStringifiableTrack({ breadth, start, state, type }) {
+ return { breadth, start, state, type };
+}
+
+exports.getStringifiableFragments = getStringifiableFragments;
+exports.stringifyGridFragments = stringifyGridFragments;
diff --git a/devtools/server/actors/utils/dbg-source.js b/devtools/server/actors/utils/dbg-source.js
new file mode 100644
index 0000000000..9c4111dfaa
--- /dev/null
+++ b/devtools/server/actors/utils/dbg-source.js
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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";
+
+/**
+ * Get the source text offset equivalent to a given line/column pair.
+ *
+ * @param {Debugger.Source} source
+ * @param {number} line The 1-based line number.
+ * @param {number} column The 0-based column number.
+ * @returns {number} The codepoint offset into the source's text.
+ */
+function findSourceOffset(source, line, column) {
+ const offsets = getSourceLineOffsets(source);
+ const offset = offsets[line - 1];
+
+ if (offset) {
+ // Make sure that columns that technically don't exist in the line text
+ // don't cause the offset to wrap to the next line.
+ return Math.min(offset.start + column, offset.textEnd);
+ }
+
+ return line < 0 ? 0 : offsets[offsets.length - 1].end;
+}
+exports.findSourceOffset = findSourceOffset;
+
+const NEWLINE = /(\r?\n|\r|\u2028|\u2029)/g;
+const SOURCE_OFFSETS = new WeakMap();
+/**
+ * Generate and cache line information for a given source to track what
+ * text offsets mark the start and end of lines. Each entry in the array
+ * represents a line in the source text.
+ *
+ * @param {Debugger.Source} source
+ * @returns {Array<{ start, textEnd, end }>}
+ * - start - The codepoint offset of the start of the line.
+ * - textEnd - The codepoint offset just after the last non-newline character.
+ * - end - The codepoint offset of the end of the line. This will be
+ * be the same as the 'start' value of the next offset object,
+ * and this includes the newlines for the line itself, where
+ * 'textEnd' excludes newline characters.
+ */
+function getSourceLineOffsets(source) {
+ const cached = SOURCE_OFFSETS.get(source);
+ if (cached) {
+ return cached;
+ }
+
+ const { text } = source;
+
+ const lines = text.split(NEWLINE);
+
+ const offsets = [];
+ let offset = 0;
+ for (let i = 0; i < lines.length; i += 2) {
+ const line = lines[i];
+ const start = offset;
+
+ // Calculate the end codepoint offset.
+ let end = offset;
+ // eslint-disable-next-line no-unused-vars
+ for (const c of line) {
+ end++;
+ }
+ const textEnd = end;
+
+ if (i + 1 < lines.length) {
+ end += lines[i + 1].length;
+ }
+
+ offsets.push(Object.freeze({ start, textEnd, end }));
+ offset = end;
+ }
+ Object.freeze(offsets);
+
+ SOURCE_OFFSETS.set(source, offsets);
+ return offsets;
+}
+
+/**
+ * Given a target actor and a source platform internal ID,
+ * return the related SourceActor ID.
+
+ * @param TargetActor targetActor
+ * The Target Actor from which this source originates.
+ * @param String id
+ * Platform Source ID
+ * @return String
+ * The SourceActor ID
+ */
+function getActorIdForInternalSourceId(targetActor, id) {
+ const actor = targetActor.sourcesManager.getSourceActorByInternalSourceId(id);
+ return actor ? actor.actorID : null;
+}
+exports.getActorIdForInternalSourceId = getActorIdForInternalSourceId;
diff --git a/devtools/server/actors/utils/event-breakpoints.js b/devtools/server/actors/utils/event-breakpoints.js
new file mode 100644
index 0000000000..5f15eda956
--- /dev/null
+++ b/devtools/server/actors/utils/event-breakpoints.js
@@ -0,0 +1,478 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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");
+
+function generalEvent(groupID, eventType) {
+ return {
+ id: `event.${groupID}.${eventType}`,
+ type: "event",
+ name: eventType,
+ message: `DOM '${eventType}' event`,
+ eventType,
+ filter: "general",
+ };
+}
+function nodeEvent(groupID, eventType) {
+ return {
+ ...generalEvent(groupID, eventType),
+ filter: "node",
+ };
+}
+function mediaNodeEvent(groupID, eventType) {
+ return {
+ ...generalEvent(groupID, eventType),
+ filter: "media",
+ };
+}
+function globalEvent(groupID, eventType) {
+ return {
+ ...generalEvent(groupID, eventType),
+ message: `Global '${eventType}' event`,
+ filter: "global",
+ };
+}
+function xhrEvent(groupID, eventType) {
+ return {
+ ...generalEvent(groupID, eventType),
+ message: `XHR '${eventType}' event`,
+ filter: "xhr",
+ };
+}
+
+function webSocketEvent(groupID, eventType) {
+ return {
+ ...generalEvent(groupID, eventType),
+ message: `WebSocket '${eventType}' event`,
+ filter: "websocket",
+ };
+}
+
+function workerEvent(eventType) {
+ return {
+ ...generalEvent("worker", eventType),
+ message: `Worker '${eventType}' event`,
+ filter: "worker",
+ };
+}
+
+function timerEvent(type, operation, name, notificationType) {
+ return {
+ id: `timer.${type}.${operation}`,
+ type: "simple",
+ name,
+ message: name,
+ notificationType,
+ };
+}
+
+function animationEvent(operation, name, notificationType) {
+ return {
+ id: `animationframe.${operation}`,
+ type: "simple",
+ name,
+ message: name,
+ notificationType,
+ };
+}
+
+const SCRIPT_FIRST_STATEMENT_BREAKPOINT = {
+ id: "script.source.firstStatement",
+ type: "script",
+ name: "Script First Statement",
+ message: "Script First Statement",
+};
+
+const AVAILABLE_BREAKPOINTS = [
+ {
+ name: "Animation",
+ items: [
+ animationEvent(
+ "request",
+ "Request Animation Frame",
+ "requestAnimationFrame"
+ ),
+ animationEvent(
+ "cancel",
+ "Cancel Animation Frame",
+ "cancelAnimationFrame"
+ ),
+ animationEvent(
+ "fire",
+ "Animation Frame fired",
+ "requestAnimationFrameCallback"
+ ),
+ ],
+ },
+ {
+ name: "Clipboard",
+ items: [
+ generalEvent("clipboard", "copy"),
+ generalEvent("clipboard", "cut"),
+ generalEvent("clipboard", "paste"),
+ generalEvent("clipboard", "beforecopy"),
+ generalEvent("clipboard", "beforecut"),
+ generalEvent("clipboard", "beforepaste"),
+ ],
+ },
+ {
+ name: "Control",
+ items: [
+ generalEvent("control", "resize"),
+ generalEvent("control", "scroll"),
+ generalEvent("control", "zoom"),
+ generalEvent("control", "focus"),
+ generalEvent("control", "blur"),
+ generalEvent("control", "select"),
+ generalEvent("control", "change"),
+ generalEvent("control", "submit"),
+ generalEvent("control", "reset"),
+ ],
+ },
+ {
+ name: "DOM Mutation",
+ items: [
+ // Deprecated DOM events.
+ nodeEvent("dom-mutation", "DOMActivate"),
+ nodeEvent("dom-mutation", "DOMFocusIn"),
+ nodeEvent("dom-mutation", "DOMFocusOut"),
+
+ // Standard DOM mutation events.
+ nodeEvent("dom-mutation", "DOMAttrModified"),
+ nodeEvent("dom-mutation", "DOMCharacterDataModified"),
+ nodeEvent("dom-mutation", "DOMNodeInserted"),
+ nodeEvent("dom-mutation", "DOMNodeInsertedIntoDocument"),
+ nodeEvent("dom-mutation", "DOMNodeRemoved"),
+ nodeEvent("dom-mutation", "DOMNodeRemovedIntoDocument"),
+ nodeEvent("dom-mutation", "DOMSubtreeModified"),
+
+ // DOM load events.
+ nodeEvent("dom-mutation", "DOMContentLoaded"),
+ ],
+ },
+ {
+ name: "Device",
+ items: [
+ globalEvent("device", "deviceorientation"),
+ globalEvent("device", "devicemotion"),
+ ],
+ },
+ {
+ name: "Drag and Drop",
+ items: [
+ generalEvent("drag-and-drop", "drag"),
+ generalEvent("drag-and-drop", "dragstart"),
+ generalEvent("drag-and-drop", "dragend"),
+ generalEvent("drag-and-drop", "dragenter"),
+ generalEvent("drag-and-drop", "dragover"),
+ generalEvent("drag-and-drop", "dragleave"),
+ generalEvent("drag-and-drop", "drop"),
+ ],
+ },
+ {
+ name: "Keyboard",
+ items: [
+ Services.prefs &&
+ Services.prefs.getBoolPref("dom.input_events.beforeinput.enabled")
+ ? generalEvent("keyboard", "beforeinput")
+ : null,
+ generalEvent("keyboard", "input"),
+ generalEvent("keyboard", "keydown"),
+ generalEvent("keyboard", "keyup"),
+ generalEvent("keyboard", "keypress"),
+ ].filter(Boolean),
+ },
+ {
+ name: "Load",
+ items: [
+ globalEvent("load", "load"),
+ // TODO: Disabled pending fixes for bug 1569775.
+ // globalEvent("load", "beforeunload"),
+ // globalEvent("load", "unload"),
+ globalEvent("load", "abort"),
+ globalEvent("load", "error"),
+ globalEvent("load", "hashchange"),
+ globalEvent("load", "popstate"),
+ ],
+ },
+ {
+ name: "Media",
+ items: [
+ mediaNodeEvent("media", "play"),
+ mediaNodeEvent("media", "pause"),
+ mediaNodeEvent("media", "playing"),
+ mediaNodeEvent("media", "canplay"),
+ mediaNodeEvent("media", "canplaythrough"),
+ mediaNodeEvent("media", "seeking"),
+ mediaNodeEvent("media", "seeked"),
+ mediaNodeEvent("media", "timeupdate"),
+ mediaNodeEvent("media", "ended"),
+ mediaNodeEvent("media", "ratechange"),
+ mediaNodeEvent("media", "durationchange"),
+ mediaNodeEvent("media", "volumechange"),
+ mediaNodeEvent("media", "loadstart"),
+ mediaNodeEvent("media", "progress"),
+ mediaNodeEvent("media", "suspend"),
+ mediaNodeEvent("media", "abort"),
+ mediaNodeEvent("media", "error"),
+ mediaNodeEvent("media", "emptied"),
+ mediaNodeEvent("media", "stalled"),
+ mediaNodeEvent("media", "loadedmetadata"),
+ mediaNodeEvent("media", "loadeddata"),
+ mediaNodeEvent("media", "waiting"),
+ ],
+ },
+ {
+ name: "Mouse",
+ items: [
+ generalEvent("mouse", "auxclick"),
+ generalEvent("mouse", "click"),
+ generalEvent("mouse", "dblclick"),
+ generalEvent("mouse", "mousedown"),
+ generalEvent("mouse", "mouseup"),
+ generalEvent("mouse", "mouseover"),
+ generalEvent("mouse", "mousemove"),
+ generalEvent("mouse", "mouseout"),
+ generalEvent("mouse", "mouseenter"),
+ generalEvent("mouse", "mouseleave"),
+ generalEvent("mouse", "mousewheel"),
+ generalEvent("mouse", "wheel"),
+ generalEvent("mouse", "contextmenu"),
+ ],
+ },
+ {
+ name: "Pointer",
+ items: [
+ generalEvent("pointer", "pointerover"),
+ generalEvent("pointer", "pointerout"),
+ generalEvent("pointer", "pointerenter"),
+ generalEvent("pointer", "pointerleave"),
+ generalEvent("pointer", "pointerdown"),
+ generalEvent("pointer", "pointerup"),
+ generalEvent("pointer", "pointermove"),
+ generalEvent("pointer", "pointercancel"),
+ generalEvent("pointer", "gotpointercapture"),
+ generalEvent("pointer", "lostpointercapture"),
+ ],
+ },
+ {
+ name: "Script",
+ items: [SCRIPT_FIRST_STATEMENT_BREAKPOINT],
+ },
+ {
+ name: "Timer",
+ items: [
+ timerEvent("timeout", "set", "setTimeout", "setTimeout"),
+ timerEvent("timeout", "clear", "clearTimeout", "clearTimeout"),
+ timerEvent("timeout", "fire", "setTimeout fired", "setTimeoutCallback"),
+ timerEvent("interval", "set", "setInterval", "setInterval"),
+ timerEvent("interval", "clear", "clearInterval", "clearInterval"),
+ timerEvent(
+ "interval",
+ "fire",
+ "setInterval fired",
+ "setIntervalCallback"
+ ),
+ ],
+ },
+ {
+ name: "Touch",
+ items: [
+ generalEvent("touch", "touchstart"),
+ generalEvent("touch", "touchmove"),
+ generalEvent("touch", "touchend"),
+ generalEvent("touch", "touchcancel"),
+ ],
+ },
+ {
+ name: "WebSocket",
+ items: [
+ webSocketEvent("websocket", "open"),
+ webSocketEvent("websocket", "message"),
+ webSocketEvent("websocket", "error"),
+ webSocketEvent("websocket", "close"),
+ ],
+ },
+ {
+ name: "Worker",
+ items: [
+ workerEvent("message"),
+ workerEvent("messageerror"),
+
+ // Service Worker events.
+ globalEvent("serviceworker", "fetch"),
+ ],
+ },
+ {
+ name: "XHR",
+ items: [
+ xhrEvent("xhr", "readystatechange"),
+ xhrEvent("xhr", "load"),
+ xhrEvent("xhr", "loadstart"),
+ xhrEvent("xhr", "loadend"),
+ xhrEvent("xhr", "abort"),
+ xhrEvent("xhr", "error"),
+ xhrEvent("xhr", "progress"),
+ xhrEvent("xhr", "timeout"),
+ ],
+ },
+];
+
+const FLAT_EVENTS = [];
+for (const category of AVAILABLE_BREAKPOINTS) {
+ for (const event of category.items) {
+ FLAT_EVENTS.push(event);
+ }
+}
+const EVENTS_BY_ID = {};
+for (const event of FLAT_EVENTS) {
+ if (EVENTS_BY_ID[event.id]) {
+ throw new Error("Duplicate event ID detected: " + event.id);
+ }
+ EVENTS_BY_ID[event.id] = event;
+}
+
+const SIMPLE_EVENTS = {};
+const DOM_EVENTS = {};
+for (const eventBP of FLAT_EVENTS) {
+ if (eventBP.type === "simple") {
+ const { notificationType } = eventBP;
+ if (SIMPLE_EVENTS[notificationType]) {
+ throw new Error("Duplicate simple event");
+ }
+ SIMPLE_EVENTS[notificationType] = eventBP.id;
+ } else if (eventBP.type === "event") {
+ const { eventType, filter } = eventBP;
+
+ let targetTypes;
+ if (filter === "global") {
+ targetTypes = ["global"];
+ } else if (filter === "xhr") {
+ targetTypes = ["xhr"];
+ } else if (filter === "websocket") {
+ targetTypes = ["websocket"];
+ } else if (filter === "worker") {
+ targetTypes = ["worker"];
+ } else if (filter === "general") {
+ targetTypes = ["global", "node"];
+ } else if (filter === "node" || filter === "media") {
+ targetTypes = ["node"];
+ } else {
+ throw new Error("Unexpected filter type");
+ }
+
+ for (const targetType of targetTypes) {
+ let byEventType = DOM_EVENTS[targetType];
+ if (!byEventType) {
+ byEventType = {};
+ DOM_EVENTS[targetType] = byEventType;
+ }
+
+ if (byEventType[eventType]) {
+ throw new Error("Duplicate dom event: " + eventType);
+ }
+ byEventType[eventType] = eventBP.id;
+ }
+ } else if (eventBP.type === "script") {
+ // Nothing to do.
+ } else {
+ throw new Error("Unknown type: " + eventBP.type);
+ }
+}
+
+exports.eventBreakpointForNotification = eventBreakpointForNotification;
+function eventBreakpointForNotification(dbg, notification) {
+ const notificationType = notification.type;
+
+ if (notification.type === "domEvent") {
+ const domEventNotification = DOM_EVENTS[notification.targetType];
+ if (!domEventNotification) {
+ return null;
+ }
+
+ // The 'event' value is a cross-compartment wrapper for the DOM Event object.
+ // While we could use that directly in the main thread as an Xray wrapper,
+ // when debugging workers we can't, because it is an opaque wrapper.
+ // To make things work, we have to always interact with the Event object via
+ // the Debugger.Object interface.
+ const evt = dbg
+ .makeGlobalObjectReference(notification.global)
+ .makeDebuggeeValue(notification.event);
+
+ const eventType = evt.getProperty("type").return;
+ const id = domEventNotification[eventType];
+ if (!id) {
+ return null;
+ }
+ const eventBreakpoint = EVENTS_BY_ID[id];
+
+ if (eventBreakpoint.filter === "media") {
+ const currentTarget = evt.getProperty("currentTarget").return;
+ if (!currentTarget) {
+ return null;
+ }
+
+ const nodeType = currentTarget.getProperty("nodeType").return;
+ const namespaceURI = currentTarget.getProperty("namespaceURI").return;
+ if (
+ nodeType !== 1 /* ELEMENT_NODE */ ||
+ namespaceURI !== "http://www.w3.org/1999/xhtml"
+ ) {
+ return null;
+ }
+
+ const nodeName = currentTarget
+ .getProperty("nodeName")
+ .return.toLowerCase();
+ if (nodeName !== "audio" && nodeName !== "video") {
+ return null;
+ }
+ }
+
+ return id;
+ }
+
+ return SIMPLE_EVENTS[notificationType] || null;
+}
+
+exports.makeEventBreakpointMessage = makeEventBreakpointMessage;
+function makeEventBreakpointMessage(id) {
+ return EVENTS_BY_ID[id].message;
+}
+
+exports.firstStatementBreakpointId = firstStatementBreakpointId;
+function firstStatementBreakpointId() {
+ return SCRIPT_FIRST_STATEMENT_BREAKPOINT.id;
+}
+
+exports.eventsRequireNotifications = eventsRequireNotifications;
+function eventsRequireNotifications(ids) {
+ for (const id of ids) {
+ const eventBreakpoint = EVENTS_BY_ID[id];
+
+ // Script events are implemented directly in the server and do not require
+ // notifications from Gecko, so there is no need to watch for them.
+ if (eventBreakpoint && eventBreakpoint.type !== "script") {
+ return true;
+ }
+ }
+ return false;
+}
+
+exports.getAvailableEventBreakpoints = getAvailableEventBreakpoints;
+function getAvailableEventBreakpoints() {
+ const available = [];
+ for (const { name, items } of AVAILABLE_BREAKPOINTS) {
+ available.push({
+ name,
+ events: items.map(item => ({
+ id: item.id,
+ name: item.name,
+ })),
+ });
+ }
+ return available;
+}
diff --git a/devtools/server/actors/utils/event-loop.js b/devtools/server/actors/utils/event-loop.js
new file mode 100644
index 0000000000..2224361e36
--- /dev/null
+++ b/devtools/server/actors/utils/event-loop.js
@@ -0,0 +1,201 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 xpcInspector = require("xpcInspector");
+const { Cu } = require("chrome");
+
+/**
+ * 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.
+ */
+function EventLoopStack({ thread }) {
+ this._thread = thread;
+}
+
+EventLoopStack.prototype = {
+ /**
+ * The number of nested event loops on the stack.
+ */
+ get size() {
+ return xpcInspector.eventLoopNestLevel;
+ },
+
+ /**
+ * The thread actor of the debuggee who pushed the event loop on top of the stack.
+ */
+ get lastPausedThreadActor() {
+ if (this.size > 0) {
+ return xpcInspector.lastNestRequestor.thread;
+ }
+ return null;
+ },
+
+ /**
+ * Push a new nested event loop onto the stack.
+ *
+ * @returns EventLoop
+ */
+ push: function() {
+ return new EventLoop({
+ thread: this._thread,
+ });
+ },
+};
+
+/**
+ * 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.
+ */
+function EventLoop({ thread }) {
+ this._thread = thread;
+
+ this.enter = this.enter.bind(this);
+ this.resolve = this.resolve.bind(this);
+}
+
+EventLoop.prototype = {
+ entered: false,
+ resolved: false,
+ get thread() {
+ return this._thread;
+ },
+
+ /**
+ * Enter this nested event loop.
+ */
+ enter: function() {
+ const preNestData = this.preNest();
+
+ 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();
+ }
+ }
+
+ this.postNest(preNestData);
+ },
+
+ /**
+ * 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;
+ },
+
+ /**
+ * Retrieve the list of all DOM Windows debugged by the current thread actor.
+ */
+ getAllWindowDebuggees() {
+ return this._thread.dbg
+ .getDebuggees()
+ .filter(debuggee => {
+ // Select only debuggee that relates to windows
+ // e.g. ignore sandboxes, jsm and such
+ return debuggee.class == "Window";
+ })
+ .map(debuggee => {
+ // Retrieve the JS reference for these windows
+ return debuggee.unsafeDereference();
+ })
+
+ .filter(window => {
+ // Ignore document which have already been nuked,
+ // so navigated to another location and removed from memory completely.
+ if (Cu.isDeadWrapper(window)) {
+ return false;
+ }
+ // Also ignore document which are closed, as trying to access window.parent or top would throw NS_ERROR_NOT_INITIALIZED
+ if (window.closed) {
+ return false;
+ }
+ // Ignore remote iframes, which will be debugged by another thread actor,
+ // running in the remote process
+ if (Cu.isRemoteProxy(window)) {
+ return false;
+ }
+ // Accept "top remote iframe document":
+ // document of iframe whose immediate parent is in another process.
+ if (Cu.isRemoteProxy(window.parent) && !Cu.isRemoteProxy(window)) {
+ return true;
+ }
+ try {
+ // Ignore iframes running in the same process as their parent document,
+ // as they will be paused automatically when pausing their owner top level document
+ return window.top === window;
+ } catch (e) {
+ // Warn if this is throwing for an unknown reason, but suppress the
+ // exception regardless so that we can enter the nested event loop.
+ if (!/not initialized/.test(e)) {
+ console.warn(`Exception in getAllWindowDebuggees: ${e}`);
+ }
+ return false;
+ }
+ });
+ },
+
+ /**
+ * Prepare to enter a nested event loop by disabling debuggee events.
+ */
+ preNest() {
+ const docShells = [];
+ // Disable events in all open windows.
+ for (const window of this.getAllWindowDebuggees()) {
+ const { windowUtils } = window;
+ windowUtils.suppressEventHandling(true);
+ windowUtils.suspendTimeouts();
+ docShells.push(window.docShell);
+ }
+ return docShells;
+ },
+
+ /**
+ * Prepare to exit a nested event loop by enabling debuggee events.
+ */
+ postNest(pausedDocShells) {
+ // Enable events in all window paused in preNest
+ for (const docShell of pausedDocShells) {
+ // Do not try to resume documents which are in destruction
+ // as resume methods would throw
+ if (docShell.isBeingDestroyed()) {
+ continue;
+ }
+ const { windowUtils } = docShell.domWindow;
+ windowUtils.resumeTimeouts();
+ windowUtils.suppressEventHandling(false);
+ }
+ },
+};
+
+exports.EventLoopStack = EventLoopStack;
diff --git a/devtools/server/actors/utils/inactive-property-helper.js b/devtools/server/actors/utils/inactive-property-helper.js
new file mode 100644
index 0000000000..75b289002c
--- /dev/null
+++ b/devtools/server/actors/utils/inactive-property-helper.js
@@ -0,0 +1,1072 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 InspectorUtils = require("InspectorUtils");
+
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "devtools/server/actors/inspector/css-logic",
+ true
+);
+
+const INACTIVE_CSS_ENABLED = Services.prefs.getBoolPref(
+ "devtools.inspector.inactive.css.enabled",
+ false
+);
+
+const VISITED_MDN_LINK = "https://developer.mozilla.org/docs/Web/CSS/:visited";
+const VISITED_INVALID_PROPERTIES = allCssPropertiesExcept([
+ "all",
+ "color",
+ "background",
+ "background-color",
+ "border",
+ "border-color",
+ "border-bottom-color",
+ "border-left-color",
+ "border-right-color",
+ "border-top-color",
+ "border-block",
+ "border-block-color",
+ "border-block-start-color",
+ "border-block-end-color",
+ "border-inline",
+ "border-inline-color",
+ "border-inline-start-color",
+ "border-inline-end-color",
+ "column-rule",
+ "column-rule-color",
+ "outline",
+ "outline-color",
+ "text-decoration-color",
+ "text-emphasis-color",
+]);
+
+class InactivePropertyHelper {
+ /**
+ * A list of rules for when CSS properties have no effect.
+ *
+ * In certain situations, CSS properties do not have any effect. A common
+ * example is trying to set a width on an inline element like a <span>.
+ *
+ * There are so many properties in CSS that it's difficult to remember which
+ * ones do and don't apply in certain situations. Some are straight-forward
+ * like `flex-wrap` only applying to an element that has `display:flex`.
+ * Others are less trivial like setting something other than a color on a
+ * `:visited` pseudo-class.
+ *
+ * This file contains "rules" in the form of objects with the following
+ * properties:
+ * {
+ * invalidProperties:
+ * Array of CSS property names that are inactive if the rule matches.
+ * when:
+ * The rule itself, a JS function used to identify the conditions
+ * indicating whether a property is valid or not.
+ * fixId:
+ * A Fluent id containing a suggested solution to the problem that is
+ * causing a property to be inactive.
+ * msgId:
+ * A Fluent id containing an error message explaining why a property is
+ * inactive in this situation.
+ * numFixProps:
+ * The number of properties we suggest in the fixId string.
+ * }
+ *
+ * If you add a new rule, also add a test for it in:
+ * server/tests/chrome/test_inspector-inactive-property-helper.html
+ *
+ * The main export is `isPropertyUsed()`, which can be used to check if a
+ * property is used or not, and why.
+ */
+ get VALIDATORS() {
+ return [
+ // Flex container property used on non-flex container.
+ {
+ invalidProperties: ["flex-direction", "flex-flow", "flex-wrap"],
+ when: () => !this.flexContainer,
+ fixId: "inactive-css-not-flex-container-fix",
+ msgId: "inactive-css-not-flex-container",
+ numFixProps: 2,
+ },
+ // Flex item property used on non-flex item.
+ {
+ invalidProperties: ["flex", "flex-basis", "flex-grow", "flex-shrink"],
+ when: () => !this.flexItem,
+ fixId: "inactive-css-not-flex-item-fix-2",
+ msgId: "inactive-css-not-flex-item",
+ numFixProps: 2,
+ },
+ // Grid container property used on non-grid container.
+ {
+ invalidProperties: [
+ "grid-auto-columns",
+ "grid-auto-flow",
+ "grid-auto-rows",
+ "grid-template",
+ "justify-items",
+ ],
+ when: () => !this.gridContainer,
+ fixId: "inactive-css-not-grid-container-fix",
+ msgId: "inactive-css-not-grid-container",
+ numFixProps: 2,
+ },
+ // Grid item property used on non-grid item.
+ {
+ invalidProperties: [
+ "grid-area",
+ "grid-column",
+ "grid-column-end",
+ "grid-column-start",
+ "grid-row",
+ "grid-row-end",
+ "grid-row-start",
+ "justify-self",
+ ],
+ when: () => !this.gridItem && !this.isAbsPosGridElement(),
+ fixId: "inactive-css-not-grid-item-fix-2",
+ msgId: "inactive-css-not-grid-item",
+ numFixProps: 2,
+ },
+ // Grid and flex item properties used on non-grid or non-flex item.
+ {
+ invalidProperties: ["align-self", "place-self", "order"],
+ when: () =>
+ !this.gridItem && !this.flexItem && !this.isAbsPosGridElement(),
+ fixId: "inactive-css-not-grid-or-flex-item-fix-2",
+ msgId: "inactive-css-not-grid-or-flex-item",
+ numFixProps: 4,
+ },
+ // Grid and flex container properties used on non-grid or non-flex container.
+ {
+ invalidProperties: [
+ "align-items",
+ "justify-content",
+ "place-content",
+ "place-items",
+ "row-gap",
+ // grid-*-gap are supported legacy shorthands for the corresponding *-gap properties.
+ // See https://drafts.csswg.org/css-align-3/#gap-legacy for more information.
+ "grid-gap",
+ "grid-row-gap",
+ ],
+ when: () => !this.gridContainer && !this.flexContainer,
+ fixId: "inactive-css-not-grid-or-flex-container-fix",
+ msgId: "inactive-css-not-grid-or-flex-container",
+ numFixProps: 2,
+ },
+ // align-content is special as align-content:baseline does have an effect on all
+ // grid items, flex items and table cells, regardless of what type of box they are.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1598730
+ {
+ invalidProperties: ["align-content"],
+ when: () =>
+ !this.style["align-content"].includes("baseline") &&
+ !this.gridContainer &&
+ !this.flexContainer,
+ fixId: "inactive-css-not-grid-or-flex-container-fix",
+ msgId: "inactive-css-not-grid-or-flex-container",
+ numFixProps: 2,
+ },
+ // column-gap and shorthands used on non-grid or non-flex or non-multi-col container.
+ {
+ invalidProperties: [
+ "column-gap",
+ "gap",
+ // grid-*-gap are supported legacy shorthands for the corresponding *-gap properties.
+ // See https://drafts.csswg.org/css-align-3/#gap-legacy for more information.
+ "grid-column-gap",
+ ],
+ when: () =>
+ !this.gridContainer && !this.flexContainer && !this.multiColContainer,
+ fixId:
+ "inactive-css-not-grid-or-flex-container-or-multicol-container-fix",
+ msgId: "inactive-css-not-grid-or-flex-container-or-multicol-container",
+ numFixProps: 3,
+ },
+ // Inline properties used on non-inline-level elements.
+ {
+ invalidProperties: ["vertical-align"],
+ when: () => {
+ const { selectorText } = this.cssRule;
+
+ const isFirstLetter =
+ selectorText && selectorText.includes("::first-letter");
+ const isFirstLine =
+ selectorText && selectorText.includes("::first-line");
+
+ return !this.isInlineLevel() && !isFirstLetter && !isFirstLine;
+ },
+ fixId: "inactive-css-not-inline-or-tablecell-fix",
+ msgId: "inactive-css-not-inline-or-tablecell",
+ numFixProps: 2,
+ },
+ // (max-|min-)width used on inline elements, table rows, or row groups.
+ {
+ invalidProperties: ["max-width", "min-width", "width"],
+ when: () =>
+ this.nonReplacedInlineBox ||
+ this.horizontalTableTrack ||
+ this.horizontalTableTrackGroup,
+ fixId: "inactive-css-non-replaced-inline-or-table-row-or-row-group-fix",
+ msgId: "inactive-css-property-because-of-display",
+ numFixProps: 2,
+ },
+ // (max-|min-)height used on inline elements, table columns, or column groups.
+ {
+ invalidProperties: ["max-height", "min-height", "height"],
+ when: () =>
+ this.nonReplacedInlineBox ||
+ this.verticalTableTrack ||
+ this.verticalTableTrackGroup,
+ fixId:
+ "inactive-css-non-replaced-inline-or-table-column-or-column-group-fix",
+ msgId: "inactive-css-property-because-of-display",
+ numFixProps: 1,
+ },
+ {
+ invalidProperties: ["display"],
+ when: () =>
+ this.isFloated &&
+ this.checkResolvedStyle("display", [
+ "inline",
+ "inline-block",
+ "inline-table",
+ "inline-flex",
+ "inline-grid",
+ "table-cell",
+ "table-row",
+ "table-row-group",
+ "table-header-group",
+ "table-footer-group",
+ "table-column",
+ "table-column-group",
+ "table-caption",
+ ]),
+ fixId: "inactive-css-not-display-block-on-floated-fix",
+ msgId: "inactive-css-not-display-block-on-floated",
+ numFixProps: 2,
+ },
+ // The property is impossible to override due to :visited restriction.
+ {
+ invalidProperties: VISITED_INVALID_PROPERTIES,
+ when: () => this.isVisitedRule(),
+ fixId: "learn-more",
+ msgId: "inactive-css-property-is-impossible-to-override-in-visited",
+ numFixProps: 1,
+ learnMoreURL: VISITED_MDN_LINK,
+ },
+ // top, right, bottom, left properties used on non positioned boxes.
+ {
+ invalidProperties: ["top", "right", "bottom", "left"],
+ when: () => !this.isPositioned,
+ fixId: "inactive-css-position-property-on-unpositioned-box-fix",
+ msgId: "inactive-css-position-property-on-unpositioned-box",
+ numFixProps: 1,
+ },
+ // z-index property used on non positioned boxes that are not grid/flex items.
+ {
+ invalidProperties: ["z-index"],
+ when: () => !this.isPositioned && !this.gridItem && !this.flexItem,
+ fixId: "inactive-css-position-property-on-unpositioned-box-fix",
+ msgId: "inactive-css-position-property-on-unpositioned-box",
+ numFixProps: 1,
+ },
+ // text-overflow property used on elements for which overflow is not set to hidden.
+ // Note that this validator only checks if overflow:hidden is set on the element.
+ // In theory, we should also be checking if the element is a block as this doesn't
+ // normally work on inline element. However there are many edge cases that made it
+ // impossible for the JS code to determine whether the type of box would support
+ // text-overflow. So, rather than risking to show invalid warnings, we decided to
+ // only warn when overflow:hidden wasn't set. There is more information about this
+ // in this discussion https://phabricator.services.mozilla.com/D62407 and on the bug
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1551578
+ {
+ invalidProperties: ["text-overflow"],
+ when: () => !this.checkComputedStyle("overflow", ["hidden"]),
+ fixId: "inactive-text-overflow-when-no-overflow-fix",
+ msgId: "inactive-text-overflow-when-no-overflow",
+ numFixProps: 1,
+ },
+ // -moz-outline-radius doesn't apply when outline-style is auto or none.
+ {
+ invalidProperties: [
+ "-moz-outline-radius",
+ "-moz-outline-radius-topleft",
+ "-moz-outline-radius-topright",
+ "-moz-outline-radius-bottomleft",
+ "-moz-outline-radius-bottomright",
+ ],
+ when: () => this.checkComputedStyle("outline-style", ["auto", "none"]),
+ fixId: "inactive-outline-radius-when-outline-style-auto-or-none-fix",
+ msgId: "inactive-outline-radius-when-outline-style-auto-or-none",
+ numFixProps: 1,
+ },
+ // margin properties used on table internal elements.
+ {
+ invalidProperties: [
+ "margin",
+ "margin-block",
+ "margin-block-end",
+ "margin-block-start",
+ "margin-bottom",
+ "margin-inline",
+ "margin-inline-end",
+ "margin-inline-start",
+ "margin-left",
+ "margin-right",
+ "margin-top",
+ ],
+ when: () => this.internalTableElement,
+ fixId: "inactive-css-not-for-internal-table-elements-fix",
+ msgId: "inactive-css-not-for-internal-table-elements",
+ numFixProps: 1,
+ },
+ // padding properties used on table internal elements except table cells.
+ {
+ invalidProperties: [
+ "padding",
+ "padding-block",
+ "padding-block-end",
+ "padding-block-start",
+ "padding-bottom",
+ "padding-inline",
+ "padding-inline-end",
+ "padding-inline-start",
+ "padding-left",
+ "padding-right",
+ "padding-top",
+ ],
+ when: () =>
+ this.internalTableElement &&
+ !this.checkComputedStyle("display", ["table-cell"]),
+ fixId:
+ "inactive-css-not-for-internal-table-elements-except-table-cells-fix",
+ msgId:
+ "inactive-css-not-for-internal-table-elements-except-table-cells",
+ numFixProps: 1,
+ },
+ ];
+ }
+
+ /**
+ * Get a list of unique CSS property names for which there are checks
+ * for used/unused state.
+ *
+ * @return {Set}
+ * List of CSS properties
+ */
+ get invalidProperties() {
+ if (!this._invalidProperties) {
+ const allProps = this.VALIDATORS.map(v => v.invalidProperties).flat();
+ this._invalidProperties = new Set(allProps);
+ }
+
+ return this._invalidProperties;
+ }
+
+ /**
+ * Is this CSS property having any effect on this element?
+ *
+ * @param {DOMNode} el
+ * The DOM element.
+ * @param {Style} elStyle
+ * The computed style for this DOMNode.
+ * @param {DOMRule} cssRule
+ * The CSS rule the property is defined in.
+ * @param {String} property
+ * The CSS property name.
+ *
+ * @return {Object} object
+ * @return {String} object.display
+ * The element computed display value.
+ * @return {String} object.fixId
+ * A Fluent id containing a suggested solution to the problem that is
+ * causing a property to be inactive.
+ * @return {String} object.msgId
+ * A Fluent id containing an error message explaining why a property
+ * is inactive in this situation.
+ * @return {Integer} object.numFixProps
+ * The number of properties we suggest in the fixId string.
+ * @return {String} object.property
+ * The inactive property name.
+ * @return {String} object.learnMoreURL
+ * An optional link if we need to open an other link than
+ * the default MDN property one.
+ * @return {Boolean} object.used
+ * true if the property is used.
+ */
+ isPropertyUsed(el, elStyle, cssRule, property) {
+ // Assume the property is used when:
+ // - the Inactive CSS pref is not enabled
+ // - the property is not in the list of properties to check
+ if (!INACTIVE_CSS_ENABLED || !this.invalidProperties.has(property)) {
+ return { used: true };
+ }
+
+ let fixId = "";
+ let msgId = "";
+ let numFixProps = 0;
+ let learnMoreURL = null;
+ let used = true;
+
+ this.VALIDATORS.some(validator => {
+ // First check if this rule cares about this property.
+ let isRuleConcerned = false;
+
+ if (validator.invalidProperties) {
+ isRuleConcerned = validator.invalidProperties.includes(property);
+ }
+
+ if (!isRuleConcerned) {
+ return false;
+ }
+
+ this.select(el, elStyle, cssRule, property);
+
+ // And then run the validator, gathering the error message if the
+ // validator passes.
+ if (validator.when()) {
+ fixId = validator.fixId;
+ msgId = validator.msgId;
+ numFixProps = validator.numFixProps;
+ learnMoreURL = validator.learnMoreURL;
+ used = false;
+
+ return true;
+ }
+
+ return false;
+ });
+
+ this.unselect();
+
+ // Accessing elStyle might throws, we wrap it in a try/catch block to avoid test
+ // failures.
+ let display;
+ try {
+ display = elStyle ? elStyle.display : null;
+ } catch (e) {}
+
+ return {
+ display,
+ fixId,
+ msgId,
+ numFixProps,
+ property,
+ learnMoreURL,
+ used,
+ };
+ }
+
+ /**
+ * Focus on a node.
+ *
+ * @param {DOMNode} node
+ * Node to focus on.
+ */
+ select(node, style, cssRule, property) {
+ this._node = node;
+ this._cssRule = cssRule;
+ this._property = property;
+ this._style = style;
+ }
+
+ /**
+ * Clear references to avoid leaks.
+ */
+ unselect() {
+ this._node = null;
+ this._cssRule = null;
+ this._property = null;
+ this._style = null;
+ }
+
+ /**
+ * Provide a public reference to node.
+ */
+ get node() {
+ return this._node;
+ }
+
+ /**
+ * Cache and provide node's computed style.
+ */
+ get style() {
+ return this._style;
+ }
+
+ /**
+ * Provide a public reference to the css rule.
+ */
+ get cssRule() {
+ return this._cssRule;
+ }
+
+ /**
+ * Check if the current node's propName is set to one of the values passed in
+ * the values array.
+ *
+ * @param {String} propName
+ * Property name to check.
+ * @param {Array} values
+ * Values to compare against.
+ */
+ checkComputedStyle(propName, values) {
+ if (!this.style) {
+ return false;
+ }
+ return values.some(value => this.style[propName] === value);
+ }
+
+ /**
+ * Check if a rule's propName is set to one of the values passed in the values
+ * array.
+ *
+ * @param {String} propName
+ * Property name to check.
+ * @param {Array} values
+ * Values to compare against.
+ */
+ checkResolvedStyle(propName, values) {
+ if (!(this.cssRule && this.cssRule.style)) {
+ return false;
+ }
+ const { style } = this.cssRule;
+
+ return values.some(value => style[propName] === value);
+ }
+
+ /**
+ * Check if the current node is an inline-level box.
+ */
+ isInlineLevel() {
+ return this.checkComputedStyle("display", [
+ "inline",
+ "inline-block",
+ "inline-table",
+ "inline-flex",
+ "inline-grid",
+ "table-cell",
+ "table-row",
+ "table-row-group",
+ "table-header-group",
+ "table-footer-group",
+ ]);
+ }
+
+ /**
+ * Check if the current node is a flex container i.e. a node that has a style
+ * of `display:flex` or `display:inline-flex`.
+ */
+ get flexContainer() {
+ return this.checkComputedStyle("display", ["flex", "inline-flex"]);
+ }
+
+ /**
+ * Check if the current node is a flex item.
+ */
+ get flexItem() {
+ return this.isFlexItem(this.node);
+ }
+
+ /**
+ * Check if the current node is a grid container i.e. a node that has a style
+ * of `display:grid` or `display:inline-grid`.
+ */
+ get gridContainer() {
+ return this.checkComputedStyle("display", ["grid", "inline-grid"]);
+ }
+
+ /**
+ * Check if the current node is a grid item.
+ */
+ get gridItem() {
+ return this.isGridItem(this.node);
+ }
+
+ /**
+ * Check if the current node is a multi-column container, i.e. a node element whose
+ * `column-width` or `column-count` property is not `auto`.
+ */
+ get multiColContainer() {
+ const autoColumnWidth = this.checkComputedStyle("column-width", ["auto"]);
+ const autoColumnCount = this.checkComputedStyle("column-count", ["auto"]);
+
+ return !autoColumnWidth || !autoColumnCount;
+ }
+
+ /**
+ * Check if the current node is a table row.
+ */
+ get tableRow() {
+ return this.style && this.style.display === "table-row";
+ }
+
+ /**
+ * Check if the current node is a table column.
+ */
+ get tableColumn() {
+ return this.style && this.style.display === "table-column";
+ }
+
+ /**
+ * Check if the current node is an internal table element.
+ */
+ get internalTableElement() {
+ return this.checkComputedStyle("display", [
+ "table-cell",
+ "table-row",
+ "table-row-group",
+ "table-header-group",
+ "table-footer-group",
+ "table-column",
+ "table-column-group",
+ ]);
+ }
+
+ /**
+ * Check if the current node is a horizontal table track. That is: either a table row
+ * displayed in horizontal writing mode, or a table column displayed in vertical writing
+ * mode.
+ */
+ get horizontalTableTrack() {
+ if (!this.tableRow && !this.tableColumn) {
+ return false;
+ }
+
+ const wm = this.getTableTrackParentWritingMode();
+ const isVertical = wm.includes("vertical") || wm.includes("sideways");
+
+ return isVertical ? this.tableColumn : this.tableRow;
+ }
+
+ /**
+ * Check if the current node is a vertical table track. That is: either a table row
+ * displayed in vertical writing mode, or a table column displayed in horizontal writing
+ * mode.
+ */
+ get verticalTableTrack() {
+ if (!this.tableRow && !this.tableColumn) {
+ return false;
+ }
+
+ const wm = this.getTableTrackParentWritingMode();
+ const isVertical = wm.includes("vertical") || wm.includes("sideways");
+
+ return isVertical ? this.tableRow : this.tableColumn;
+ }
+
+ /**
+ * Check if the current node is a row group.
+ */
+ get rowGroup() {
+ return this.isRowGroup(this.node);
+ }
+
+ /**
+ * Check if the current node is a table column group.
+ */
+ get columnGroup() {
+ return this.isColumnGroup(this.node);
+ }
+
+ /**
+ * Check if the current node is a horizontal table track group. That is: either a table
+ * row group displayed in horizontal writing mode, or a table column group displayed in
+ * vertical writing mode.
+ */
+ get horizontalTableTrackGroup() {
+ if (!this.rowGroup && !this.columnGroup) {
+ return false;
+ }
+
+ const wm = this.getTableTrackParentWritingMode(true);
+ const isVertical = wm.includes("vertical") || wm.includes("sideways");
+
+ const isHorizontalRowGroup = this.rowGroup && !isVertical;
+ const isHorizontalColumnGroup = this.columnGroup && isVertical;
+
+ return isHorizontalRowGroup || isHorizontalColumnGroup;
+ }
+
+ /**
+ * Check if the current node is a vertical table track group. That is: either a table row
+ * group displayed in vertical writing mode, or a table column group displayed in
+ * horizontal writing mode.
+ */
+ get verticalTableTrackGroup() {
+ if (!this.rowGroup && !this.columnGroup) {
+ return false;
+ }
+
+ const wm = this.getTableTrackParentWritingMode(true);
+ const isVertical = wm.includes("vertical") || wm.includes("sideways");
+
+ const isVerticalRowGroup = this.rowGroup && isVertical;
+ const isVerticalColumnGroup = this.columnGroup && !isVertical;
+
+ return isVerticalRowGroup || isVerticalColumnGroup;
+ }
+
+ /**
+ * Returns whether this element uses CSS layout.
+ */
+ get hasCssLayout() {
+ return !this.isSvg && !this.isMathMl;
+ }
+
+ /**
+ * Check if the current node is a non-replaced CSS inline box.
+ */
+ get nonReplacedInlineBox() {
+ return (
+ this.hasCssLayout &&
+ this.nonReplaced &&
+ this.style &&
+ this.style.display === "inline"
+ );
+ }
+
+ /**
+ * Check if the current node is a non-replaced element. See `replaced()` for
+ * a description of what a replaced element is.
+ */
+ get nonReplaced() {
+ return !this.replaced;
+ }
+
+ /**
+ * Check if the current node is an absolutely-positioned element.
+ */
+ get isAbsolutelyPositioned() {
+ return this.checkComputedStyle("position", ["absolute", "fixed"]);
+ }
+
+ /**
+ * Check if the current node is positioned (i.e. its position property has a value other
+ * than static).
+ */
+ get isPositioned() {
+ return this.checkComputedStyle("position", [
+ "relative",
+ "absolute",
+ "fixed",
+ "sticky",
+ ]);
+ }
+
+ /**
+ * Check if the current node is floated
+ */
+ get isFloated() {
+ return this.style && this.style.cssFloat !== "none";
+ }
+
+ /**
+ * Check if the current node is a replaced element i.e. an element with
+ * content that will be replaced e.g. <img>, <audio>, <video> or <object>
+ * elements.
+ */
+ get replaced() {
+ // The <applet> element was removed in Gecko 56 so we can ignore them.
+ // These are always treated as replaced elements:
+ if (
+ this.nodeNameOneOf([
+ "audio",
+ "br",
+ "button",
+ "canvas",
+ "embed",
+ "hr",
+ "iframe",
+ // Inputs are generally replaced elements. E.g. checkboxes and radios are replaced
+ // unless they have `appearance: none`. However unconditionally treating them
+ // as replaced is enough for our purpose here, and avoids extra complexity that
+ // will likely not be necessary in most cases.
+ "input",
+ "math",
+ "object",
+ "picture",
+ // Select is a replaced element if it has `size<=1` or no size specified, but
+ // unconditionally treating it as replaced is enough for our purpose here, and
+ // avoids extra complexity that will likely not be necessary in most cases.
+ "select",
+ "svg",
+ "textarea",
+ "video",
+ ])
+ ) {
+ return true;
+ }
+
+ // img tags are replaced elements only when the image has finished loading.
+ if (this.nodeName === "img" && this.node.complete) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Return the current node's nodeName.
+ *
+ * @returns {String}
+ */
+ get nodeName() {
+ return this.node.nodeName ? this.node.nodeName.toLowerCase() : null;
+ }
+
+ /**
+ * Return whether the node is a MathML element.
+ */
+ get isMathMl() {
+ return this.node.namespaceURI === "http://www.w3.org/1998/Math/MathML";
+ }
+
+ /**
+ * Return whether the node is an SVG element.
+ */
+ get isSvg() {
+ return this.node.namespaceURI === "http://www.w3.org/2000/svg";
+ }
+
+ /**
+ * Check if the current node's nodeName matches a value inside the value array.
+ *
+ * @param {Array} values
+ * Array of values to compare against.
+ * @returns {Boolean}
+ */
+ nodeNameOneOf(values) {
+ return values.includes(this.nodeName);
+ }
+
+ /**
+ * Check if the current node is an absolutely-positioned grid element.
+ * See: https://drafts.csswg.org/css-grid/#abspos-items
+ *
+ * @return {Boolean} whether or not the current node is absolutely-positioned by a
+ * grid container.
+ */
+ isAbsPosGridElement() {
+ if (!this.isAbsolutelyPositioned) {
+ return false;
+ }
+
+ const containingBlock = this.getContainingBlock();
+
+ return containingBlock !== null && this.isGridContainer(containingBlock);
+ }
+
+ /**
+ * Check if a node is a flex item.
+ *
+ * @param {DOMNode} node
+ * The node to check.
+ */
+ isFlexItem(node) {
+ return !!node.parentFlexElement;
+ }
+
+ /**
+ * Check if a node is a flex container.
+ *
+ * @param {DOMNode} node
+ * The node to check.
+ */
+ isFlexContainer(node) {
+ return !!node.getAsFlexContainer();
+ }
+
+ /**
+ * Check if a node is a grid container.
+ *
+ * @param {DOMNode} node
+ * The node to check.
+ */
+ isGridContainer(node) {
+ return node.hasGridFragments();
+ }
+
+ /**
+ * Check if a node is a grid item.
+ *
+ * @param {DOMNode} node
+ * The node to check.
+ */
+ isGridItem(node) {
+ return !!this.getParentGridElement(this.node);
+ }
+
+ isVisitedRule() {
+ if (!CssLogic.hasVisitedState(this.node)) {
+ return false;
+ }
+
+ const selectors = CssLogic.getSelectors(this.cssRule);
+ if (!selectors.some(s => s.endsWith(":visited"))) {
+ return false;
+ }
+
+ const { bindingElement, pseudo } = CssLogic.getBindingElementAndPseudo(
+ this.node
+ );
+
+ for (let i = 0; i < selectors.length; i++) {
+ if (
+ !selectors[i].endsWith(":visited") &&
+ InspectorUtils.selectorMatchesElement(
+ bindingElement,
+ this.cssRule,
+ i,
+ pseudo,
+ true
+ )
+ ) {
+ // Match non :visited selector.
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Return the current node's ancestor that generates its containing block.
+ */
+ getContainingBlock() {
+ return this.node ? InspectorUtils.containingBlockOf(this.node) : null;
+ }
+
+ getParentGridElement(node) {
+ // The documentElement can't be a grid item, only a container, so bail out.
+ if (node.flattenedTreeParentNode === node.ownerDocument) {
+ return null;
+ }
+
+ if (node.nodeType === node.ELEMENT_NODE) {
+ const display = this.style ? this.style.display : null;
+
+ if (!display || display === "none" || display === "contents") {
+ // Doesn't generate a box, not a grid item.
+ return null;
+ }
+ if (this.isAbsolutelyPositioned) {
+ // Out of flow, not a grid item.
+ return null;
+ }
+ } else if (node.nodeType !== node.TEXT_NODE) {
+ return null;
+ }
+
+ for (
+ let p = node.flattenedTreeParentNode;
+ p;
+ p = p.flattenedTreeParentNode
+ ) {
+ if (this.isGridContainer(p)) {
+ // It's a grid item!
+ return p;
+ }
+
+ const style = computedStyle(p, node.ownerGlobal);
+ const display = style.display;
+
+ if (display !== "contents") {
+ return null; // Not a grid item, for sure.
+ }
+ // display: contents, walk to the parent
+ }
+ return null;
+ }
+
+ isRowGroup(node) {
+ const style = node === this.node ? this.style : computedStyle(node);
+
+ return (
+ style &&
+ (style.display === "table-row-group" ||
+ style.display === "table-header-group" ||
+ style.display === "table-footer-group")
+ );
+ }
+
+ isColumnGroup(node) {
+ const style = node === this.node ? this.style : computedStyle(node);
+
+ return style && style.display === "table-column-group";
+ }
+
+ /**
+ * Assuming the current element is a table track (row or column) or table track group,
+ * get the parent table writing mode.
+ * This is either going to be the table element if there is one, or the parent element.
+ * If the current element is not a table track, this returns its own writing mode.
+ *
+ * @param {Boolean} isGroup
+ * Whether the element is a table track group, instead of a table track.
+ * @return {String}
+ * The writing-mode value
+ */
+ getTableTrackParentWritingMode(isGroup) {
+ let current = this.node.parentNode;
+
+ // Skip over unrendered elements.
+ while (computedStyle(current).display === "contents") {
+ current = current.parentNode;
+ }
+
+ // Skip over groups if the initial element wasn't already one.
+ if (!isGroup && (this.isRowGroup(current) || this.isColumnGroup(current))) {
+ current = current.parentNode;
+ }
+
+ // Once more over unrendered elements above the group.
+ while (computedStyle(current).display === "contents") {
+ current = current.parentNode;
+ }
+
+ return computedStyle(current).writingMode;
+ }
+}
+
+exports.inactivePropertyHelper = new InactivePropertyHelper();
+
+/**
+ * Returns all CSS property names except given properties.
+ *
+ * @param {Array} - propertiesToIgnore
+ * Array of property ignored.
+ * @return {Array}
+ * Array of all CSS property name except propertiesToIgnore.
+ */
+function allCssPropertiesExcept(propertiesToIgnore) {
+ const properties = new Set(
+ InspectorUtils.getCSSPropertyNames({ includeAliases: true })
+ );
+
+ for (const name of propertiesToIgnore) {
+ properties.delete(name);
+ }
+
+ return [...properties];
+}
+
+/**
+ * Helper for getting an element's computed styles.
+ *
+ * @param {DOMNode} node
+ * The node to get the styles for.
+ * @param {Window} window
+ * Optional window object. If omitted, will get the node's window.
+ * @return {Object}
+ */
+function computedStyle(node, window = node.ownerGlobal) {
+ return window.getComputedStyle(node);
+}
diff --git a/devtools/server/actors/utils/logEvent.js b/devtools/server/actors/utils/logEvent.js
new file mode 100644
index 0000000000..052acb6ec4
--- /dev/null
+++ b/devtools/server/actors/utils/logEvent.js
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { formatDisplayName } = require("devtools/server/actors/frame");
+const {
+ TYPES,
+ getResourceWatcher,
+} = require("devtools/server/actors/resources/index");
+
+// Get a string message to display when a frame evaluation throws.
+function getThrownMessage(completion) {
+ try {
+ if (completion.throw.getOwnPropertyDescriptor) {
+ return completion.throw.getOwnPropertyDescriptor("message").value;
+ } else if (completion.toString) {
+ return completion.toString();
+ }
+ } catch (ex) {
+ // ignore
+ }
+ return "Unknown exception";
+}
+module.exports.getThrownMessage = getThrownMessage;
+
+function logEvent({ threadActor, frame, level, expression, bindings }) {
+ const {
+ sourceActor,
+ line,
+ column,
+ } = threadActor.sourcesManager.getFrameLocation(frame);
+ const displayName = formatDisplayName(frame);
+
+ // TODO remove this branch when (#1592584) lands (#1609540)
+ if (isWorker) {
+ threadActor._parent._consoleActor.evaluateJS({
+ text: `console.log(...${expression})`,
+ bindings: { displayName, ...bindings },
+ url: sourceActor.url,
+ lineNumber: line,
+ });
+
+ return undefined;
+ }
+
+ const completion = frame.evalWithBindings(expression, {
+ displayName,
+ ...bindings,
+ });
+
+ let value;
+ if (!completion) {
+ // The evaluation was killed (possibly by the slow script dialog).
+ value = ["Evaluation failed"];
+ } else if ("return" in completion) {
+ value = completion.return;
+ } else {
+ value = [getThrownMessage(completion)];
+ level = `${level}Error`;
+ }
+
+ if (value && typeof value.unsafeDereference === "function") {
+ value = value.unsafeDereference();
+ }
+
+ const message = {
+ filename: sourceActor.url,
+ lineNumber: line,
+ columnNumber: column,
+ arguments: value,
+ level,
+ // The 'prepareConsoleMessageForRemote' method in webconsoleActor expects internal source ID,
+ // thus we can't set sourceId directly to sourceActorID.
+ sourceId: sourceActor.internalSourceId,
+ };
+
+ const targetActor = threadActor._parent;
+ // Note that only BrowsingContextTarget actor support resource watcher
+ // This is still missing for worker and content processes
+ const consoleMessageWatcher = getResourceWatcher(
+ targetActor,
+ TYPES.CONSOLE_MESSAGE
+ );
+ if (consoleMessageWatcher) {
+ consoleMessageWatcher.onLogPoint(message);
+ } else {
+ // Bug 1642296: Once we enable ConsoleMessage resource on the server, we should remove onConsoleAPICall
+ // from the WebConsoleActor, and only support the ConsoleMessageWatcher codepath.
+ targetActor._consoleActor.onConsoleAPICall(message);
+ }
+
+ return undefined;
+}
+
+module.exports.logEvent = logEvent;
diff --git a/devtools/server/actors/utils/make-debugger.js b/devtools/server/actors/utils/make-debugger.js
new file mode 100644
index 0000000000..e916b0aae9
--- /dev/null
+++ b/devtools/server/actors/utils/make-debugger.js
@@ -0,0 +1,110 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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.
+ *
+ * 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;
+
+ const onNewGlobalObject = function(global) {
+ if (shouldAddNewGlobalAsDebuggee(global)) {
+ safeAddDebuggee(this, global);
+ }
+ };
+
+ dbg.onNewGlobalObject = onNewGlobalObject;
+ dbg.addDebuggees = function() {
+ for (const global of findDebuggees(this)) {
+ safeAddDebuggee(this, global);
+ }
+ };
+
+ dbg.disable = function() {
+ dbg.removeAllDebuggees();
+ dbg.onNewGlobalObject = undefined;
+ };
+
+ dbg.enable = function() {
+ dbg.addDebuggees();
+ dbg.onNewGlobalObject = onNewGlobalObject;
+ };
+
+ return dbg;
+};
+
+const reportDebuggerHookException = e => reportException("DBG-SERVER", e);
+
+/**
+ * Add |global| as a debuggee to |dbg|, handling error cases.
+ */
+function safeAddDebuggee(dbg, global) {
+ let globalDO;
+ try {
+ globalDO = dbg.addDebuggee(global);
+ } catch (e) {
+ // Ignoring attempt to add the debugger's compartment as a debuggee.
+ return;
+ }
+
+ if (dbg.onNewDebuggee) {
+ dbg.onNewDebuggee(globalDO);
+ }
+}
diff --git a/devtools/server/actors/utils/moz.build b/devtools/server/actors/utils/moz.build
new file mode 100644
index 0000000000..128ea1678e
--- /dev/null
+++ b/devtools/server/actors/utils/moz.build
@@ -0,0 +1,28 @@
+# -*- 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(
+ "accessibility.js",
+ "actor-registry.js",
+ "breakpoint-actor-map.js",
+ "capture-screenshot.js",
+ "css-grid-utils.js",
+ "dbg-source.js",
+ "event-breakpoints.js",
+ "event-loop.js",
+ "inactive-property-helper.js",
+ "logEvent.js",
+ "make-debugger.js",
+ "shapes-utils.js",
+ "source-map-utils.js",
+ "source-url.js",
+ "sources-manager.js",
+ "stack.js",
+ "style-utils.js",
+ "track-change-emitter.js",
+ "walker-search.js",
+ "watchpoint-map.js",
+)
diff --git a/devtools/server/actors/utils/shapes-utils.js b/devtools/server/actors/utils/shapes-utils.js
new file mode 100644
index 0000000000..aab50bf952
--- /dev/null
+++ b/devtools/server/actors/utils/shapes-utils.js
@@ -0,0 +1,149 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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";
+
+/**
+ * Get the distance between two points on a plane.
+ * @param {Number} x1 the x coord of the first point
+ * @param {Number} y1 the y coord of the first point
+ * @param {Number} x2 the x coord of the second point
+ * @param {Number} y2 the y coord of the second point
+ * @returns {Number} the distance between the two points
+ */
+const getDistance = (x1, y1, x2, y2) => {
+ return Math.round(Math.hypot(x2 - x1, y2 - y1));
+};
+
+/**
+ * Determine if the given x/y coords are along the edge of the given ellipse.
+ * We allow for a small area around the edge that still counts as being on the edge.
+ * @param {Number} x the x coordinate of the click
+ * @param {Number} y the y coordinate of the click
+ * @param {Number} cx the x coordinate of the center of the ellipse
+ * @param {Number} cy the y coordinate of the center of the ellipse
+ * @param {Number} rx the x radius of the ellipse
+ * @param {Number} ry the y radius of the ellipse
+ * @param {Number} clickWidthX the width of the area that counts as being on the edge
+ * along the x radius.
+ * @param {Number} clickWidthY the width of the area that counts as being on the edge
+ * along the y radius.
+ * @returns {Boolean} whether the click counts as being on the edge of the ellipse.
+ */
+const clickedOnEllipseEdge = (
+ x,
+ y,
+ cx,
+ cy,
+ rx,
+ ry,
+ clickWidthX,
+ clickWidthY
+) => {
+ // The formula to determine if something is inside or on the edge of an ellipse is:
+ // (x - cx)^2/rx^2 + (y - cy)^2/ry^2 <= 1. If > 1, it's outside.
+ // We make two ellipses, adjusting rx and ry with clickWidthX and clickWidthY
+ // to allow for an area around the edge of the ellipse that can be clicked on.
+ // If the click was outside the inner ellipse and inside the outer ellipse, return true.
+ const inner =
+ (x - cx) ** 2 / (rx - clickWidthX) ** 2 +
+ (y - cy) ** 2 / (ry - clickWidthY) ** 2;
+ const outer =
+ (x - cx) ** 2 / (rx + clickWidthX) ** 2 +
+ (y - cy) ** 2 / (ry + clickWidthY) ** 2;
+ return inner >= 1 && outer <= 1;
+};
+
+/**
+ * Get the distance between a point and a line defined by two other points.
+ * @param {Number} x1 the x coordinate of the first point in the line
+ * @param {Number} y1 the y coordinate of the first point in the line
+ * @param {Number} x2 the x coordinate of the second point in the line
+ * @param {Number} y2 the y coordinate of the second point in the line
+ * @param {Number} x3 the x coordinate of the point for which the distance is found
+ * @param {Number} y3 the y coordinate of the point for which the distance is found
+ * @returns {Number} the distance between (x3,y3) and the line defined by
+ * (x1,y1) and (y1,y2)
+ */
+const distanceToLine = (x1, y1, x2, y2, x3, y3) => {
+ // https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_two_points
+ const num = Math.abs((y2 - y1) * x3 - (x2 - x1) * y3 + x2 * y1 - y2 * x1);
+ const denom = getDistance(x1, y1, x2, y2);
+ return num / denom;
+};
+
+/**
+ * Get the point on the line defined by points a,b that is closest to point c
+ * @param {Number} ax the x coordinate of point a
+ * @param {Number} ay the y coordinate of point a
+ * @param {Number} bx the x coordinate of point b
+ * @param {Number} by the y coordinate of point b
+ * @param {Number} cx the x coordinate of point c
+ * @param {Number} cy the y coordinate of point c
+ * @returns {Array} a 2 element array that contains the x/y coords of the projected point
+ */
+const projection = (ax, ay, bx, by, cx, cy) => {
+ // https://en.wikipedia.org/wiki/Vector_projection#Vector_projection_2
+ const ab = [bx - ax, by - ay];
+ const ac = [cx - ax, cy - ay];
+ const scalar = dotProduct(ab, ac) / dotProduct(ab, ab);
+ return [ax + scalar * ab[0], ay + scalar * ab[1]];
+};
+
+/**
+ * Get the dot product of two vectors, represented by arrays of numbers.
+ * @param {Array} a the first vector
+ * @param {Array} b the second vector
+ * @returns {Number} the dot product of a and b
+ */
+const dotProduct = (a, b) => {
+ return a.reduce((prev, curr, i) => {
+ return prev + curr * b[i];
+ }, 0);
+};
+
+/**
+ * Determine if the given x/y coords are above the given point.
+ * @param {Number} x the x coordinate of the click
+ * @param {Number} y the y coordinate of the click
+ * @param {Number} pointX the x coordinate of the center of the point
+ * @param {Number} pointY the y coordinate of the center of the point
+ * @param {Number} radiusX the x radius of the point
+ * @param {Number} radiusY the y radius of the point
+ * @returns {Boolean} whether the click was on the point
+ */
+const clickedOnPoint = (x, y, pointX, pointY, radiusX, radiusY) => {
+ return (
+ x >= pointX - radiusX &&
+ x <= pointX + radiusX &&
+ y >= pointY - radiusY &&
+ y <= pointY + radiusY
+ );
+};
+
+const roundTo = (value, exp) => {
+ // If the exp is undefined or zero...
+ if (typeof exp === "undefined" || +exp === 0) {
+ return Math.round(value);
+ }
+ value = +value;
+ exp = +exp;
+ // If the value is not a number or the exp is not an integer...
+ if (isNaN(value) || !(typeof exp === "number" && exp % 1 === 0)) {
+ return NaN;
+ }
+ // Shift
+ value = value.toString().split("e");
+ value = Math.round(+(value[0] + "e" + (value[1] ? +value[1] - exp : -exp)));
+ // Shift back
+ value = value.toString().split("e");
+ return +(value[0] + "e" + (value[1] ? +value[1] + exp : exp));
+};
+
+exports.getDistance = getDistance;
+exports.clickedOnEllipseEdge = clickedOnEllipseEdge;
+exports.distanceToLine = distanceToLine;
+exports.projection = projection;
+exports.clickedOnPoint = clickedOnPoint;
+exports.roundTo = roundTo;
diff --git a/devtools/server/actors/utils/source-map-utils.js b/devtools/server/actors/utils/source-map-utils.js
new file mode 100644
index 0000000000..5df3bca0e5
--- /dev/null
+++ b/devtools/server/actors/utils/source-map-utils.js
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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";
+
+exports.resolveSourceURL = resolveSourceURL;
+function resolveSourceURL(sourceURL, global) {
+ if (sourceURL) {
+ try {
+ return new URL(sourceURL, global?.location?.href || undefined).href;
+ } catch (err) {}
+ }
+
+ return null;
+}
+
+exports.getSourcemapBaseURL = getSourcemapBaseURL;
+function getSourcemapBaseURL(url, global) {
+ let sourceMapBaseURL = null;
+ if (url) {
+ // Sources that have explicit URLs can be used directly as the base.
+ sourceMapBaseURL = url;
+ } else if (global?.location?.href) {
+ // If there is no URL for the source, the map comment is relative to the
+ // page being viewed, so we use the document href.
+ sourceMapBaseURL = global?.location?.href;
+ } else {
+ // If there is no valid base, the sourcemap URL will need to be an absolute
+ // URL of some kind.
+ return null;
+ }
+
+ // A data URL is large and will never be a valid base, so we can just treat
+ // it as if there is no base at all to avoid a sending it to the client
+ // for no reason.
+ if (sourceMapBaseURL.startsWith("data:")) {
+ return null;
+ }
+
+ // If the base URL is a blob, we want to resolve relative to the origin
+ // that created the blob URL, if there is one.
+ if (sourceMapBaseURL.startsWith("blob:")) {
+ try {
+ const parsedBaseURL = new URL(sourceMapBaseURL);
+ return parsedBaseURL.origin === "null" ? null : parsedBaseURL.origin;
+ } catch (err) {
+ return null;
+ }
+ }
+
+ return sourceMapBaseURL;
+}
diff --git a/devtools/server/actors/utils/source-url.js b/devtools/server/actors/utils/source-url.js
new file mode 100644
index 0000000000..133853b978
--- /dev/null
+++ b/devtools/server/actors/utils/source-url.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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";
+
+/**
+ * Debugger.Source objects have a `url` property that exposes the value
+ * that was passed to SpiderMonkey, but unfortunately often SpiderMonkey
+ * sets a URL even in cases where it doesn't make sense, so we have to
+ * explicitly ignore the URL value in these contexts to keep things a bit
+ * more consistent.
+ *
+ * @param {Debugger.Source} source
+ *
+ * @return {string | null}
+ */
+function getDebuggerSourceURL(source) {
+ const introType = source.introductionType;
+
+ // These are all the sources that are eval or eval-like, but may still have
+ // a URL set on the source, so we explicitly ignore the source URL for these.
+ if (
+ introType === "injectedScript" ||
+ introType === "eval" ||
+ introType === "debugger eval" ||
+ introType === "Function" ||
+ introType === "javascriptURL" ||
+ introType === "eventHandler" ||
+ introType === "domTimer"
+ ) {
+ return null;
+ }
+
+ return source.url;
+}
+
+exports.getDebuggerSourceURL = getDebuggerSourceURL;
diff --git a/devtools/server/actors/utils/sources-manager.js b/devtools/server/actors/utils/sources-manager.js
new file mode 100644
index 0000000000..0cefe17d89
--- /dev/null
+++ b/devtools/server/actors/utils/sources-manager.js
@@ -0,0 +1,503 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 DevToolsUtils = require("devtools/shared/DevToolsUtils");
+const { assert, fetch } = DevToolsUtils;
+const EventEmitter = require("devtools/shared/event-emitter");
+const { SourceLocation } = require("devtools/server/actors/common");
+const Services = require("Services");
+
+loader.lazyRequireGetter(
+ this,
+ "SourceActor",
+ "devtools/server/actors/source",
+ true
+);
+
+/**
+ * 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$/;
+
+/**
+ * Manages the sources for a thread. Handles URL contents, locations in
+ * the sources, etc for ThreadActors.
+ */
+class SourcesManager extends EventEmitter {
+ constructor(threadActor, allowSourceFn = () => true) {
+ super();
+ this._thread = threadActor;
+ this.allowSource = source => {
+ return !isHiddenSource(source) && allowSourceFn(source);
+ };
+
+ this.blackBoxedSources = new Map();
+
+ // Debugger.Source -> SourceActor
+ this._sourceActors = new Map();
+
+ // URL -> content
+ //
+ // Any possibly incomplete content that has been loaded for each URL.
+ this._urlContents = new Map();
+
+ // URL -> Promise[]
+ //
+ // Any promises waiting on a URL to be completely loaded.
+ this._urlWaiters = new Map();
+
+ // Debugger.Source.id -> Debugger.Source
+ //
+ // The IDs associated with ScriptSources and available via DebuggerSource.id
+ // are internal to this process and should not be exposed to the client. This
+ // map associates these IDs with the corresponding source, provided the source
+ // has not been GC'ed and the actor has been created. This is lazily populated
+ // the first time it is needed.
+ this._sourcesByInternalSourceId = null;
+
+ if (!isWorker) {
+ Services.obs.addObserver(this, "devtools-html-content");
+ }
+ }
+
+ destroy() {
+ if (!isWorker) {
+ Services.obs.removeObserver(this, "devtools-html-content");
+ }
+ }
+
+ /**
+ * Clear existing sources so they are recreated on the next access.
+ */
+ reset() {
+ this._sourceActors = new Map();
+ this._urlContents = new Map();
+ this._urlWaiters = new Map();
+ this._sourcesByInternalSourceId = null;
+ }
+
+ /**
+ * Create a source actor representing this source.
+ *
+ * @param Debugger.Source source
+ * The source to make an actor for.
+ * @returns a SourceActor representing the source or null.
+ */
+ createSourceActor(source) {
+ assert(source, "SourcesManager.prototype.source needs a source");
+
+ if (!this.allowSource(source)) {
+ return null;
+ }
+
+ if (this._sourceActors.has(source)) {
+ return this._sourceActors.get(source);
+ }
+
+ const actor = new SourceActor({
+ thread: this._thread,
+ source,
+ });
+
+ this._thread.threadLifetimePool.manage(actor);
+
+ this._sourceActors.set(source, actor);
+ if (this._sourcesByInternalSourceId && source.id) {
+ this._sourcesByInternalSourceId.set(source.id, source);
+ }
+
+ this.emit("newSource", actor);
+ return actor;
+ }
+
+ _getSourceActor(source) {
+ if (this._sourceActors.has(source)) {
+ return this._sourceActors.get(source);
+ }
+
+ return null;
+ }
+
+ hasSourceActor(source) {
+ return !!this._getSourceActor(source);
+ }
+
+ getSourceActor(source) {
+ const sourceActor = this._getSourceActor(source);
+
+ if (!sourceActor) {
+ throw new Error(
+ "getSource: could not find source actor for " + (source.url || "source")
+ );
+ }
+
+ return sourceActor;
+ }
+
+ getOrCreateSourceActor(source) {
+ // Tolerate the source coming from a different Debugger than the one
+ // associated with the thread.
+ try {
+ source = this._thread.dbg.adoptSource(source);
+ } catch (e) {
+ // We can't create actors for sources in the same compartment as the
+ // thread's Debugger.
+ if (/is in the same compartment as this debugger/.test(e)) {
+ return null;
+ }
+ throw e;
+ }
+
+ if (this.hasSourceActor(source)) {
+ return this.getSourceActor(source);
+ }
+ return this.createSourceActor(source);
+ }
+
+ getSourceActorByInternalSourceId(id) {
+ if (!this._sourcesByInternalSourceId) {
+ this._sourcesByInternalSourceId = new Map();
+ for (const source of this._thread.dbg.findSources()) {
+ if (source.id) {
+ this._sourcesByInternalSourceId.set(source.id, source);
+ }
+ }
+ }
+ const source = this._sourcesByInternalSourceId.get(id);
+ if (source) {
+ return this.getOrCreateSourceActor(source);
+ }
+ return null;
+ }
+
+ getSourceActorsByURL(url) {
+ const rv = [];
+ if (url) {
+ for (const [, actor] of this._sourceActors) {
+ if (actor.url === url) {
+ rv.push(actor);
+ }
+ }
+ }
+ return rv;
+ }
+
+ getSourceActorById(actorId) {
+ for (const [, actor] of this._sourceActors) {
+ if (actor.actorID == actorId) {
+ return actor;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns true if the URL likely points to a minified resource, false
+ * otherwise.
+ *
+ * @param String uri
+ * The url to test.
+ * @returns Boolean
+ */
+ _isMinifiedURL(uri) {
+ if (!uri) {
+ return false;
+ }
+
+ try {
+ const url = new URL(uri);
+ const 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(uri);
+ }
+ }
+
+ /**
+ * Return the non-source-mapped location of an offset in a script.
+ *
+ * @param Debugger.Script script
+ * The script associated with the offset.
+ * @param Number offset
+ * Offset within the script of the location.
+ * @returns Object
+ * Returns an object of the form { source, line, column }
+ */
+ getScriptOffsetLocation(script, offset) {
+ const { lineNumber, columnNumber } = script.getOffsetMetadata(offset);
+ return new SourceLocation(
+ this.createSourceActor(script.source),
+ lineNumber,
+ columnNumber
+ );
+ }
+
+ /**
+ * 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 frame
+ * The frame whose location we are getting.
+ * @returns Object
+ * Returns an object of the form { source, line, column }
+ */
+ getFrameLocation(frame) {
+ if (!frame || !frame.script) {
+ return new SourceLocation();
+ }
+ return this.getScriptOffsetLocation(frame.script, frame.offset);
+ }
+
+ /**
+ * Returns true if URL for the given source is black boxed.
+ *
+ * * @param url String
+ * The URL of the source which we are checking whether it is black
+ * boxed or not.
+ */
+ isBlackBoxed(url, line, column) {
+ const ranges = this.blackBoxedSources.get(url);
+ if (!ranges) {
+ return this.blackBoxedSources.has(url);
+ }
+
+ const range = ranges.find(r => isLocationInRange({ line, column }, r));
+ return !!range;
+ }
+
+ isFrameBlackBoxed(frame) {
+ const { url, line, column } = this.getFrameLocation(frame);
+ return this.isBlackBoxed(url, line, column);
+ }
+
+ /**
+ * Add the given source URL to the set of sources that are black boxed.
+ *
+ * @param url String
+ * The URL of the source which we are black boxing.
+ */
+ blackBox(url, range) {
+ if (!range) {
+ // blackbox the whole source
+ return this.blackBoxedSources.set(url, null);
+ }
+
+ const ranges = this.blackBoxedSources.get(url) || [];
+ // ranges are sorted in ascening order
+ const index = ranges.findIndex(
+ r => r.end.line <= range.start.line && r.end.column <= range.start.column
+ );
+
+ ranges.splice(index + 1, 0, range);
+ this.blackBoxedSources.set(url, ranges);
+ return true;
+ }
+
+ /**
+ * Remove the given source URL to the set of sources that are black boxed.
+ *
+ * @param url String
+ * The URL of the source which we are no longer black boxing.
+ */
+ unblackBox(url, range) {
+ if (!range) {
+ return this.blackBoxedSources.delete(url);
+ }
+
+ const ranges = this.blackBoxedSources.get(url);
+ const index = ranges.findIndex(
+ r =>
+ r.start.line === range.start.line &&
+ r.start.column === range.start.column &&
+ r.end.line === range.end.line &&
+ r.end.column === range.end.column
+ );
+
+ if (index !== -1) {
+ ranges.splice(index, 1);
+ }
+
+ if (ranges.length === 0) {
+ return this.blackBoxedSources.delete(url);
+ }
+
+ return this.blackBoxedSources.set(url, ranges);
+ }
+
+ iter() {
+ return [...this._sourceActors.values()];
+ }
+
+ /**
+ * Listener for new HTML content.
+ */
+ observe(subject, topic, data) {
+ if (topic == "devtools-html-content") {
+ const { parserID, uri, contents, complete } = JSON.parse(data);
+ if (this._urlContents.has(uri)) {
+ const existing = this._urlContents.get(uri);
+ if (existing.parserID == parserID) {
+ assert(!existing.complete);
+ existing.content = existing.content + contents;
+ existing.complete = complete;
+
+ // After the HTML has finished loading, resolve any promises
+ // waiting for the complete file contents. Waits will only
+ // occur when the URL was ever partially loaded.
+ if (complete) {
+ const waiters = this._urlWaiters.get(uri);
+ if (waiters) {
+ for (const waiter of waiters) {
+ waiter();
+ }
+ this._urlWaiters.delete(uri);
+ }
+ }
+ }
+ } else {
+ this._urlContents.set(uri, {
+ content: contents,
+ complete,
+ contentType: "text/html",
+ parserID,
+ });
+ }
+ }
+ }
+
+ /**
+ * Get the contents of a URL, fetching it if necessary. If partial is set and
+ * any content for the URL has been received, that partial content is returned
+ * synchronously.
+ */
+ urlContents(url, partial, canUseCache) {
+ if (this._urlContents.has(url)) {
+ const data = this._urlContents.get(url);
+ if (!partial && !data.complete) {
+ return new Promise(resolve => {
+ if (!this._urlWaiters.has(url)) {
+ this._urlWaiters.set(url, []);
+ }
+ this._urlWaiters.get(url).push(resolve);
+ }).then(() => {
+ assert(data.complete);
+ return {
+ content: data.content,
+ contentType: data.contentType,
+ };
+ });
+ }
+ return {
+ content: data.content,
+ contentType: data.contentType,
+ };
+ }
+
+ return this._fetchURLContents(url, partial, canUseCache);
+ }
+
+ async _fetchURLContents(url, partial, canUseCache) {
+ // Only try the cache if it is currently enabled for the document.
+ // Without this check, the cache may return stale data that doesn't match
+ // the document shown in the browser.
+ let loadFromCache = canUseCache;
+ if (canUseCache && this._thread._parent._getCacheDisabled) {
+ loadFromCache = !this._thread._parent._getCacheDisabled();
+ }
+
+ // Fetch the sources with the same principal as the original document
+ const win = this._thread._parent.window;
+ let principal, cacheKey;
+ // On xpcshell, we don't have a window but a Sandbox
+ if (!isWorker && win instanceof Ci.nsIDOMWindow) {
+ const docShell = win.docShell;
+ const channel = docShell.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 &&
+ docShell.currentDocumentChannel instanceof Ci.nsICacheInfoChannel
+ ) {
+ cacheKey = docShell.currentDocumentChannel.cacheKey;
+ }
+ }
+
+ let result;
+ try {
+ result = await fetch(url, {
+ principal,
+ cacheKey,
+ loadFromCache,
+ });
+ } catch (error) {
+ this._reportLoadSourceError(error);
+ throw error;
+ }
+
+ // When we fetch the contents, there is a risk that the contents we get
+ // do not match up with the actual text of the sources these contents will
+ // be associated with. We want to always show contents that include that
+ // actual text (otherwise it will be very confusing or unusable for users),
+ // so replace the contents with the actual text if there is a mismatch.
+ const actors = [...this._sourceActors.values()].filter(
+ actor => actor.url == url
+ );
+ if (!actors.every(actor => actor.contentMatches(result))) {
+ if (actors.length > 1) {
+ // When there are multiple actors we won't be able to show the source
+ // for all of them. Ask the user to reload so that we don't have to do
+ // any fetching.
+ result.content = "Error: Incorrect contents fetched, please reload.";
+ } else {
+ result.content = actors[0].actualText();
+ }
+ }
+
+ this._urlContents.set(url, { ...result, complete: true });
+
+ return result;
+ }
+
+ _reportLoadSourceError(error) {
+ try {
+ DevToolsUtils.reportException("SourceActor", error);
+
+ const lines = JSON.stringify(this.form(), null, 4).split(/\n/g);
+ lines.forEach(line => console.error("\t", line));
+ } catch (e) {
+ // ignore
+ }
+ }
+}
+
+/*
+ * 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(source) {
+ return source.introductionType === "Function.prototype";
+}
+
+function isLocationInRange({ line, column }, range) {
+ return (
+ (range.start.line <= line ||
+ (range.start.line == line && range.start.column <= column)) &&
+ (range.end.line >= line ||
+ (range.end.line == line && range.end.column >= column))
+ );
+}
+
+exports.SourcesManager = SourcesManager;
+exports.isHiddenSource = isHiddenSource;
diff --git a/devtools/server/actors/utils/stack.js b/devtools/server/actors/utils/stack.js
new file mode 100644
index 0000000000..6a216b252c
--- /dev/null
+++ b/devtools/server/actors/utils/stack.js
@@ -0,0 +1,183 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * A 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.
+ */
+class StackFrameCache {
+ /**
+ * Initialize this object.
+ */
+ constructor() {
+ this._framesToIndices = null;
+ this._framesToForms = null;
+ this._lastEventSize = 0;
+ }
+
+ /**
+ * Prepare to accept frames.
+ */
+ initFrames() {
+ 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() {
+ 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(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(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 (const [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() {
+ const size = this._framesToForms.size;
+ if (!size || size <= this._lastEventSize) {
+ return null;
+ }
+
+ const packet = Array(size - this._lastEventSize).fill(null);
+ for (const [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(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(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/style-utils.js b/devtools/server/actors/utils/style-utils.js
new file mode 100644
index 0000000000..bd7520ed15
--- /dev/null
+++ b/devtools/server/actors/utils/style-utils.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 { getCSSLexer } = require("devtools/shared/css/lexer");
+
+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";
+// Offset (in px) to avoid cutting off text edges of italic fonts.
+const FONT_PREVIEW_OFFSET = 4;
+
+/**
+ * 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 || {};
+ const previewText = options.previewText || FONT_PREVIEW_TEXT;
+ const previewFontSize = options.previewFontSize || FONT_PREVIEW_FONT_SIZE;
+ const fillStyle = options.fillStyle || FONT_PREVIEW_FILLSTYLE;
+ const fontStyle = options.fontStyle || "";
+
+ const canvas = doc.createElementNS(XHTML_NS, "canvas");
+ const ctx = canvas.getContext("2d");
+ const fontValue =
+ fontStyle + " " + previewFontSize + "px " + font + ", serif";
+
+ // Get the correct preview text measurements and set the canvas dimensions
+ ctx.font = fontValue;
+ ctx.fillStyle = fillStyle;
+ const textWidth = Math.round(ctx.measureText(previewText).width);
+
+ canvas.width = textWidth * 2 + FONT_PREVIEW_OFFSET * 4;
+ 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)
+ );
+
+ const 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");
+ }
+
+ const { offset: textOffset, text } = getTextAtLineColumn(
+ initialText,
+ line,
+ column
+ );
+ const lexer = getCSSLexer(text);
+
+ // Search forward for the opening brace.
+ while (true) {
+ const 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) {
+ const 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;
+
+/**
+ * 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) {
+ const rx = new RegExp(
+ "(?:[^\\r\\n\\f]*(?:\\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/utils/track-change-emitter.js b/devtools/server/actors/utils/track-change-emitter.js
new file mode 100644
index 0000000000..e2b34c3b0c
--- /dev/null
+++ b/devtools/server/actors/utils/track-change-emitter.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EventEmitter = require("devtools/shared/event-emitter");
+
+/**
+ * A helper class that is listened to by the ChangesActor, and can be
+ * used to send changes to the ChangesActor.
+ */
+class TrackChangeEmitter {
+ /**
+ * Initialize this object.
+ */
+ constructor() {
+ EventEmitter.decorate(this);
+ }
+
+ trackChange(change) {
+ this.emit("track-change", change);
+ }
+}
+
+module.exports = new TrackChangeEmitter();
diff --git a/devtools/server/actors/utils/walker-search.js b/devtools/server/actors/utils/walker-search.js
new file mode 100644
index 0000000000..3e75524c12
--- /dev/null
+++ b/devtools/server/actors/utils/walker-search.js
@@ -0,0 +1,313 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+loader.lazyRequireGetter(
+ this,
+ "isWhitespaceTextNode",
+ "devtools/server/actors/inspector/utils",
+ true
+);
+
+/**
+ * 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.
+ */
+
+/**
+ * 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
+ const 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;
+
+ const documentWalker = this.walker.getDocumentWalker(this.doc);
+ while (documentWalker.nextNode()) {
+ const node = documentWalker.currentNode;
+
+ if (node.nodeType === 1) {
+ // For each element node, we get the tagname and all attributes names
+ // and values
+ const localName = node.localName;
+ if (localName === "_moz_generated_content_marker") {
+ this._addToIndex("tag", node, "::marker");
+ this._addToIndex("text", node, node.textContent.trim());
+ } else 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 (const { 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, []);
+ }
+
+ const matches = results.get(node);
+
+ // Do not add if the exact same result is already in the list
+ let isKnown = false;
+ for (const match of matches) {
+ if (match.type === type) {
+ isKnown = true;
+ break;
+ }
+ }
+
+ if (!isKnown) {
+ matches.push({ type });
+ }
+ },
+
+ _searchIndex: function(query, options, results) {
+ for (const [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.includes(entry.type);
+ })
+ .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
+ const isSelector = query && query.match(/[ >~.#\[\]]/);
+ if (!options.types.includes("selector") || !isSelector) {
+ return;
+ }
+
+ const nodes = this.walker._multiFrameQuerySelectorAll(query);
+ for (const node of nodes) {
+ this._addResult(node, "selector", results);
+ }
+ },
+
+ _searchXPath: function(query, options, results) {
+ if (!options.types.includes("xpath")) {
+ return;
+ }
+
+ const nodes = this.walker._multiFrameXPath(query);
+ for (const node of nodes) {
+ // Exclude text nodes that only contain whitespace
+ // because they are not displayed in the Inspector.
+ if (!isWhitespaceTextNode(node)) {
+ this._addResult(node, "xpath", 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 and XPath search types)
+ * - 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
+ const results = new Map();
+
+ // Search through the indexed data
+ this._searchIndex(query, options, results);
+
+ // Search with querySelectorAll
+ this._searchSelectors(query, options, results);
+
+ // Search with XPath
+ this._searchXPath(query, options, results);
+
+ // Concatenate all results into an Array to return
+ const resultList = [];
+ for (const [node, matches] of results) {
+ for (const { 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;
+ }
+ }
+
+ const documents = this.walker.targetActor.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) {
+ const indA = documents.indexOf(a.node.ownerDocument);
+ const 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().includes(query.toLowerCase());
+};
+
+WalkerSearch.ALL_RESULTS_TYPES = [
+ "tag",
+ "text",
+ "attributeName",
+ "attributeValue",
+ "selector",
+ "xpath",
+];
+
+exports.WalkerSearch = WalkerSearch;
diff --git a/devtools/server/actors/utils/watchpoint-map.js b/devtools/server/actors/utils/watchpoint-map.js
new file mode 100644
index 0000000000..d053fa3b71
--- /dev/null
+++ b/devtools/server/actors/utils/watchpoint-map.js
@@ -0,0 +1,163 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class WatchpointMap {
+ constructor(threadActor) {
+ this.threadActor = threadActor;
+ this._watchpoints = new Map();
+ }
+
+ _setWatchpoint(objActor, data) {
+ const { property, label, watchpointType } = data;
+ const obj = objActor.rawValue();
+
+ const desc = objActor.obj.getOwnPropertyDescriptor(property);
+
+ if (this.has(obj, property) || desc.set || desc.get || !desc.configurable) {
+ return null;
+ }
+
+ function getValue() {
+ return typeof desc.value === "object" && desc.value
+ ? desc.value.unsafeDereference()
+ : desc.value;
+ }
+
+ function setValue(v) {
+ desc.value = objActor.obj.makeDebuggeeValue(v);
+ }
+
+ const maybeHandlePause = type => {
+ const frame = this.threadActor.dbg.getNewestFrame();
+
+ if (
+ !this.threadActor.hasMoved(frame, type) ||
+ this.threadActor.skipBreakpointsOption ||
+ this.threadActor.sourcesManager.isFrameBlackBoxed(frame)
+ ) {
+ return;
+ }
+
+ this.threadActor._pauseAndRespond(frame, {
+ type: type,
+ message: label,
+ });
+ };
+
+ if (watchpointType === "get") {
+ objActor.obj.defineProperty(property, {
+ configurable: desc.configurable,
+ enumerable: desc.enumerable,
+ set: objActor.obj.makeDebuggeeValue(v => {
+ setValue(v);
+ }),
+ get: objActor.obj.makeDebuggeeValue(() => {
+ maybeHandlePause("getWatchpoint");
+ return getValue();
+ }),
+ });
+ }
+
+ if (watchpointType === "set") {
+ objActor.obj.defineProperty(property, {
+ configurable: desc.configurable,
+ enumerable: desc.enumerable,
+ set: objActor.obj.makeDebuggeeValue(v => {
+ maybeHandlePause("setWatchpoint");
+ setValue(v);
+ }),
+ get: objActor.obj.makeDebuggeeValue(() => {
+ return getValue();
+ }),
+ });
+ }
+
+ if (watchpointType === "getorset") {
+ objActor.obj.defineProperty(property, {
+ configurable: desc.configurable,
+ enumerable: desc.enumerable,
+ set: objActor.obj.makeDebuggeeValue(v => {
+ maybeHandlePause("setWatchpoint");
+ setValue(v);
+ }),
+ get: objActor.obj.makeDebuggeeValue(() => {
+ maybeHandlePause("getWatchpoint");
+ return getValue();
+ }),
+ });
+ }
+
+ return desc;
+ }
+
+ add(objActor, data) {
+ // Get the object's description before calling setWatchpoint,
+ // otherwise we'll get the modified property descriptor instead
+ const desc = this._setWatchpoint(objActor, data);
+ if (!desc) {
+ return;
+ }
+
+ const objWatchpoints =
+ this._watchpoints.get(objActor.rawValue()) || new Map();
+
+ objWatchpoints.set(data.property, { ...data, desc });
+ this._watchpoints.set(objActor.rawValue(), objWatchpoints);
+ }
+
+ has(obj, property) {
+ const objWatchpoints = this._watchpoints.get(obj);
+ return objWatchpoints && objWatchpoints.has(property);
+ }
+
+ get(obj, property) {
+ const objWatchpoints = this._watchpoints.get(obj);
+ return objWatchpoints && objWatchpoints.get(property);
+ }
+
+ remove(objActor, property) {
+ const obj = objActor.rawValue();
+
+ // This should remove watchpoints on all of the object's properties if
+ // a property isn't passed in as an argument
+ if (!property) {
+ for (const objProperty in obj) {
+ this.remove(objActor, objProperty);
+ }
+ }
+
+ if (!this.has(obj, property)) {
+ return;
+ }
+
+ const objWatchpoints = this._watchpoints.get(obj);
+ const { desc } = objWatchpoints.get(property);
+
+ objWatchpoints.delete(property);
+ this._watchpoints.set(obj, objWatchpoints);
+
+ // We should stop keeping track of an object if it no longer
+ // has a watchpoint
+ if (objWatchpoints.size == 0) {
+ this._watchpoints.delete(obj);
+ }
+
+ objActor.obj.defineProperty(property, desc);
+ }
+
+ removeAll(objActor) {
+ const objWatchpoints = this._watchpoints.get(objActor.rawValue());
+ if (!objWatchpoints) {
+ return;
+ }
+
+ for (const objProperty in objWatchpoints) {
+ this.remove(objActor, objProperty);
+ }
+ }
+}
+
+exports.WatchpointMap = WatchpointMap;