summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/utils
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /devtools/server/actors/utils
parentInitial commit. (diff)
downloadfirefox-upstream/124.0.1.tar.xz
firefox-upstream/124.0.1.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/actors/utils')
-rw-r--r--devtools/server/actors/utils/accessibility.js103
-rw-r--r--devtools/server/actors/utils/actor-registry.js418
-rw-r--r--devtools/server/actors/utils/breakpoint-actor-map.js84
-rw-r--r--devtools/server/actors/utils/capture-screenshot.js200
-rw-r--r--devtools/server/actors/utils/css-grid-utils.js60
-rw-r--r--devtools/server/actors/utils/custom-formatters.js499
-rw-r--r--devtools/server/actors/utils/dbg-source.js97
-rw-r--r--devtools/server/actors/utils/event-breakpoints.js508
-rw-r--r--devtools/server/actors/utils/event-loop.js221
-rw-r--r--devtools/server/actors/utils/gecko-profile-collector.js285
-rw-r--r--devtools/server/actors/utils/inactive-property-helper.js1443
-rw-r--r--devtools/server/actors/utils/logEvent.js112
-rw-r--r--devtools/server/actors/utils/make-debugger.js122
-rw-r--r--devtools/server/actors/utils/moz.build32
-rw-r--r--devtools/server/actors/utils/shapes-utils.js149
-rw-r--r--devtools/server/actors/utils/source-map-utils.js42
-rw-r--r--devtools/server/actors/utils/source-url.js44
-rw-r--r--devtools/server/actors/utils/sources-manager.js515
-rw-r--r--devtools/server/actors/utils/stack.js183
-rw-r--r--devtools/server/actors/utils/style-utils.js211
-rw-r--r--devtools/server/actors/utils/stylesheet-utils.js155
-rw-r--r--devtools/server/actors/utils/stylesheets-manager.js1031
-rw-r--r--devtools/server/actors/utils/track-change-emitter.js19
-rw-r--r--devtools/server/actors/utils/walker-search.js320
-rw-r--r--devtools/server/actors/utils/watchpoint-map.js163
25 files changed, 7016 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..ee8ee9ccd0
--- /dev/null
+++ b/devtools/server/actors/utils/accessibility.js
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+loader.lazyRequireGetter(
+ this,
+ ["loadSheet", "removeSheet"],
+ "resource://devtools/shared/layout/utils.js",
+ 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: initial !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);
+}
+
+/**
+ * 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.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..ae88c38c6b
--- /dev/null
+++ b/devtools/server/actors/utils/actor-registry.js
@@ -0,0 +1,418 @@
+/* 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 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 },
+ }
+ );
+
+ this.registerModule("devtools/server/actors/screenshot", {
+ prefix: "screenshot",
+ constructor: "ScreenshotActor",
+ 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/memory", {
+ prefix: "memory",
+ constructor: "MemoryActor",
+ 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 },
+ });
+ 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/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/changes", {
+ prefix: "changes",
+ constructor: "ChangesActor",
+ 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 },
+ }
+ );
+ this.registerModule("devtools/server/actors/screenshot-content", {
+ prefix: "screenshotContent",
+ constructor: "ScreenshotContentActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/tracer", {
+ prefix: "tracer",
+ constructor: "TracerActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/objects-manager", {
+ prefix: "objectsManager",
+ constructor: "ObjectsManagerActor",
+ 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..cce32b2833
--- /dev/null
+++ b/devtools/server/actors/utils/breakpoint-actor-map.js
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ BreakpointActor,
+} = require("resource://devtools/server/actors/breakpoint.js");
+
+/**
+ * A BreakpointActorMap is a map from locations to instances of BreakpointActor.
+ */
+class BreakpointActorMap {
+ constructor(threadActor) {
+ this._threadActor = threadActor;
+ this._actors = {};
+ }
+
+ // 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];
+ }
+
+ /**
+ * Unregister all currently active breakpoints.
+ */
+ removeAllBreakpoints() {
+ for (const bpActor of Object.values(this._actors)) {
+ bpActor.removeScripts();
+ }
+ this._actors = {};
+ }
+}
+
+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..e7b46620b2
--- /dev/null
+++ b/devtools/server/actors/utils/capture-screenshot.js
@@ -0,0 +1,200 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+
+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/selector/uicontrol.js
+const MAX_IMAGE_WIDTH = 10000;
+const MAX_IMAGE_HEIGHT = 10000;
+
+/**
+ * This function is called to simulate camera effects
+ * @param {BrowsingContext} browsingContext: The browsing context associated with the
+ * browser element we want to animate.
+ */
+function simulateCameraFlash(browsingContext) {
+ // If there's no topFrameElement (it can happen if the screenshot is taken from the
+ // browser toolbox), use the top chrome window document element.
+ const node =
+ browsingContext.topFrameElement ||
+ browsingContext.topChromeWindow.document.documentElement;
+
+ if (!node) {
+ console.error(
+ "Can't find an element to play the camera flash animation on for the following browsing context:",
+ browsingContext
+ );
+ return;
+ }
+
+ // Don't take a screenshot if the user prefers reduced motion.
+ if (node.ownerGlobal.matchMedia("(prefers-reduced-motion)").matches) {
+ return;
+ }
+
+ node.animate([{ opacity: 0 }, { opacity: 1 }], {
+ duration: CONTAINER_FLASHING_DURATION,
+ });
+}
+
+/**
+ * Take a screenshot of a browser element given its browsingContext.
+ *
+ * @param {Object} args
+ * @param {Number} args.delay: Number of seconds to wait before taking the screenshot
+ * @param {Object|null} args.rect: Object with left, top, width and height properties
+ * representing the rect **inside the browser element** that should
+ * be rendered. If null, the current viewport of the element will be rendered.
+ * @param {Boolean} args.fullpage: Should the screenshot be the height of the whole page
+ * @param {String} args.filename: Expected filename for the screenshot
+ * @param {Number} args.snapshotScale: Scale that will be used by `drawSnapshot` to take the screenshot.
+ * ⚠️ Note that the scale might be decreased if the resulting image would
+ * be too big to draw safely. A warning message will be returned if that's
+ * the case.
+ * @param {Number} args.fileScale: Scale of the exported file. Defaults to args.snapshotScale.
+ * @param {Boolean} args.disableFlash: Set to true to disable the flash animation when the
+ * screenshot is taken.
+ * @param {BrowsingContext} browsingContext
+ * @returns {Object} object with the following properties:
+ * - data {String}: The dataURL representing the screenshot
+ * - height {Number}: Height of the resulting screenshot
+ * - width {Number}: Width of the resulting screenshot
+ * - filename {String}: Filename of the resulting screenshot
+ * - messages {Array<Object{text, level}>}: An array of object representing the
+ * different messages and their level that should be displayed to the user.
+ */
+async function captureScreenshot(args, browsingContext) {
+ const messages = [];
+
+ let filename = getFilename(args.filename);
+
+ if (args.fullpage) {
+ filename = filename.replace(".png", "-fullpage.png");
+ }
+
+ let { left, top, width, height } = args.rect || {};
+
+ // Truncate the width and height if necessary.
+ if (width && (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT)) {
+ width = Math.min(width, MAX_IMAGE_WIDTH);
+ height = Math.min(height, MAX_IMAGE_HEIGHT);
+ messages.push({
+ level: "warn",
+ text: L10N.getFormatStr("screenshotTruncationWarning", width, height),
+ });
+ }
+
+ let rect = null;
+ if (args.rect) {
+ rect = new globalThis.DOMRect(
+ Math.round(left),
+ Math.round(top),
+ Math.round(width),
+ Math.round(height)
+ );
+ }
+
+ const document = browsingContext.topChromeWindow.document;
+ const canvas = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+
+ const drawToCanvas = async 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 {
+ const snapshot = await browsingContext.currentWindowGlobal.drawSnapshot(
+ rect,
+ actualRatio,
+ "rgb(255,255,255)",
+ args.fullpage
+ );
+
+ const fileScale = args.fileScale || actualRatio;
+ const renderingWidth = (snapshot.width / actualRatio) * fileScale;
+ const renderingHeight = (snapshot.height / actualRatio) * fileScale;
+ canvas.width = renderingWidth;
+ canvas.height = renderingHeight;
+ width = renderingWidth;
+ height = renderingHeight;
+ const ctx = canvas.getContext("2d");
+ ctx.drawImage(snapshot, 0, 0, renderingWidth, renderingHeight);
+
+ // Bug 1574935 - Huge dimensions can trigger an OOM because multiple copies
+ // of the bitmap will exist in memory. Force the removal of the snapshot
+ // because it is no longer needed.
+ snapshot.close();
+
+ return canvas.toDataURL("image/png", "");
+ } catch (e) {
+ return null;
+ }
+ };
+
+ const ratio = args.snapshotScale;
+ let data = await 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.
+ messages.push({
+ level: "warn",
+ text: L10N.getStr("screenshotDPRDecreasedWarning"),
+ });
+ data = await drawToCanvas(1.0);
+ }
+ if (!data) {
+ messages.push({
+ level: "error",
+ text: L10N.getStr("screenshotRenderingError"),
+ });
+ }
+
+ if (data && args.disableFlash !== true) {
+ simulateCameraFlash(browsingContext);
+ }
+
+ return {
+ data,
+ height,
+ width,
+ filename,
+ messages,
+ };
+}
+
+exports.captureScreenshot = captureScreenshot;
+
+/**
+ * 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();
+ const monthString = (date.getMonth() + 1).toString().padStart(2, "0");
+ const dayString = date.getDate().toString().padStart(2, "0");
+ const dateString = `${date.getFullYear()}-${monthString}-${dayString}`;
+
+ const timeString = date.toTimeString().replace(/:/g, ".").split(" ")[0];
+
+ return (
+ L10N.getFormatStr("screenshotGeneratedFilename", dateString, timeString) +
+ ".png"
+ );
+}
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..9631dcd800
--- /dev/null
+++ b/devtools/server/actors/utils/css-grid-utils.js
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * 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);
+}
+
+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;
diff --git a/devtools/server/actors/utils/custom-formatters.js b/devtools/server/actors/utils/custom-formatters.js
new file mode 100644
index 0000000000..e4ae20dad7
--- /dev/null
+++ b/devtools/server/actors/utils/custom-formatters.js
@@ -0,0 +1,499 @@
+/* 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,
+ "createValueGripForTarget",
+ "resource://devtools/server/actors/object/utils.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "ObjectUtils",
+ "resource://devtools/server/actors/object/utils.js"
+);
+
+const _invalidCustomFormatterHooks = new WeakSet();
+function addInvalidCustomFormatterHooks(hook) {
+ if (!hook) {
+ return;
+ }
+
+ try {
+ _invalidCustomFormatterHooks.add(hook);
+ } catch (e) {
+ console.error("Couldn't add hook to the WeakSet", hook);
+ }
+}
+
+// Custom exception used between customFormatterHeader and processFormatterForHeader
+class FormatterError extends Error {
+ constructor(message, script) {
+ super(message);
+ this.script = script;
+ }
+}
+
+/**
+ * Handle a protocol request to get the custom formatter header for an object.
+ * This is typically returned into ObjectActor's form if custom formatters are enabled.
+ *
+ * @param {ObjectActor} objectActor
+ *
+ * @returns {Object} Data related to the custom formatter header:
+ * - {boolean} useCustomFormatter, indicating if a custom formatter is used.
+ * - {Array} header JsonML of the output header.
+ * - {boolean} hasBody True in case the custom formatter has a body.
+ * - {Object} formatter The devtoolsFormatters item that was being used to format
+ * the object.
+ */
+function customFormatterHeader(objectActor) {
+ const rawValue = objectActor.rawValue();
+ const globalWrapper = Cu.getGlobalForObject(rawValue);
+ const global = globalWrapper?.wrappedJSObject;
+
+ // We expect a `devtoolsFormatters` global attribute and it to be an array
+ if (!global || !Array.isArray(global.devtoolsFormatters)) {
+ return null;
+ }
+
+ const customFormatterTooDeep =
+ (objectActor.hooks.customFormatterObjectTagDepth || 0) > 20;
+ if (customFormatterTooDeep) {
+ logCustomFormatterError(
+ globalWrapper,
+ `Too deep hierarchy of inlined custom previews`
+ );
+ return null;
+ }
+
+ const targetActor = objectActor.thread._parent;
+
+ const {
+ customFormatterConfigDbgObj: configDbgObj,
+ customFormatterObjectTagDepth,
+ } = objectActor.hooks;
+
+ const valueDbgObj = objectActor.obj;
+
+ for (const [
+ customFormatterIndex,
+ formatter,
+ ] of global.devtoolsFormatters.entries()) {
+ // If the message for the erroneous formatter already got logged,
+ // skip logging it again.
+ if (_invalidCustomFormatterHooks.has(formatter)) {
+ continue;
+ }
+
+ // TODO: Any issues regarding the implementation will be covered in https://bugzil.la/1776611.
+ try {
+ const rv = processFormatterForHeader({
+ configDbgObj,
+ customFormatterObjectTagDepth,
+ formatter,
+ targetActor,
+ valueDbgObj,
+ });
+ // Return the first valid formatter value
+ if (rv) {
+ return rv;
+ }
+ } catch (e) {
+ logCustomFormatterError(
+ globalWrapper,
+ e instanceof FormatterError
+ ? `devtoolsFormatters[${customFormatterIndex}].${e.message}`
+ : `devtoolsFormatters[${customFormatterIndex}] couldn't be run: ${e.message}`,
+ // If the exception is FormatterError, this comes with a script attribute
+ e.script
+ );
+ addInvalidCustomFormatterHooks(formatter);
+ }
+ }
+
+ return null;
+}
+exports.customFormatterHeader = customFormatterHeader;
+
+/**
+ * Handle one precise custom formatter.
+ * i.e. one element of the window.customFormatters Array.
+ *
+ * @param {Object} options
+ * @param {Debugger.Object} options.configDbgObj
+ * The Debugger.Object of the config object.
+ * @param {Number} options.customFormatterObjectTagDepth
+ * See buildJsonMlFromCustomFormatterHookResult JSDoc.
+ * @param {Object} options.formatter
+ * The raw formatter object (coming from "customFormatter" array).
+ * @param {BrowsingContextTargetActor} options.targetActor
+ * See buildJsonMlFromCustomFormatterHookResult JSDoc.
+ * @param {Debugger.Object} options.valueDbgObj
+ * The Debugger.Object of rawValue.
+ *
+ * @returns {Object} See customFormatterHeader jsdoc, it returns the same object.
+ */
+function processFormatterForHeader({
+ configDbgObj,
+ customFormatterObjectTagDepth,
+ formatter,
+ targetActor,
+ valueDbgObj,
+}) {
+ const headerType = typeof formatter?.header;
+ if (headerType !== "function") {
+ throw new FormatterError(`header should be a function, got ${headerType}`);
+ }
+
+ // Call the formatter's header attribute, which should be a function.
+ const formatterHeaderDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded(
+ valueDbgObj,
+ formatter.header
+ );
+ const header = formatterHeaderDbgValue.call(
+ formatterHeaderDbgValue.boundThis,
+ valueDbgObj,
+ configDbgObj
+ );
+
+ // If the header returns null, the custom formatter isn't used for that object
+ if (header?.return === null) {
+ return null;
+ }
+
+ // The header has to be an Array, all other cases are errors
+ if (header?.return?.class !== "Array") {
+ let errorMsg = "";
+ if (header == null) {
+ errorMsg = `header was not run because it has side effects`;
+ } else if ("return" in header) {
+ let type = typeof header.return;
+ if (type === "object") {
+ type = header.return?.class;
+ }
+ errorMsg = `header should return an array, got ${type}`;
+ } else if ("throw" in header) {
+ errorMsg = `header threw: ${header.throw.getProperty("message")?.return}`;
+ }
+
+ throw new FormatterError(errorMsg, formatterHeaderDbgValue?.script);
+ }
+
+ const rawHeader = header.return.unsafeDereference();
+ if (rawHeader.length === 0) {
+ throw new FormatterError(
+ `header returned an empty array`,
+ formatterHeaderDbgValue?.script
+ );
+ }
+
+ const sanitizedHeader = buildJsonMlFromCustomFormatterHookResult(
+ header.return,
+ customFormatterObjectTagDepth,
+ targetActor
+ );
+
+ let hasBody = false;
+ const hasBodyType = typeof formatter?.hasBody;
+ if (hasBodyType === "function") {
+ const formatterHasBodyDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded(
+ valueDbgObj,
+ formatter.hasBody
+ );
+ hasBody = formatterHasBodyDbgValue.call(
+ formatterHasBodyDbgValue.boundThis,
+ valueDbgObj,
+ configDbgObj
+ );
+
+ if (hasBody == null) {
+ throw new FormatterError(
+ `hasBody was not run because it has side effects`,
+ formatterHasBodyDbgValue?.script
+ );
+ } else if ("throw" in hasBody) {
+ throw new FormatterError(
+ `hasBody threw: ${hasBody.throw.getProperty("message")?.return}`,
+ formatterHasBodyDbgValue?.script
+ );
+ }
+ } else if (hasBodyType !== "undefined") {
+ throw new FormatterError(
+ `hasBody should be a function, got ${hasBodyType}`
+ );
+ }
+
+ return {
+ useCustomFormatter: true,
+ header: sanitizedHeader,
+ hasBody: !!hasBody?.return,
+ formatter,
+ };
+}
+
+/**
+ * Handle a protocol request to get the custom formatter body for an object
+ *
+ * @param {ObjectActor} objectActor
+ * @param {Object} formatter: The global.devtoolsFormatters entry that was used in customFormatterHeader
+ * for this object.
+ *
+ * @returns {Object} Data related to the custom formatter body:
+ * - {*} customFormatterBody Data of the custom formatter body.
+ */
+async function customFormatterBody(objectActor, formatter) {
+ const rawValue = objectActor.rawValue();
+ const globalWrapper = Cu.getGlobalForObject(rawValue);
+ const global = globalWrapper?.wrappedJSObject;
+
+ const customFormatterIndex = global.devtoolsFormatters.indexOf(formatter);
+
+ const targetActor = objectActor.thread._parent;
+ try {
+ const { customFormatterConfigDbgObj, customFormatterObjectTagDepth } =
+ objectActor.hooks;
+
+ if (_invalidCustomFormatterHooks.has(formatter)) {
+ return {
+ customFormatterBody: null,
+ };
+ }
+
+ const bodyType = typeof formatter.body;
+ if (bodyType !== "function") {
+ logCustomFormatterError(
+ globalWrapper,
+ `devtoolsFormatters[${customFormatterIndex}].body should be a function, got ${bodyType}`
+ );
+ addInvalidCustomFormatterHooks(formatter);
+ return {
+ customFormatterBody: null,
+ };
+ }
+
+ const formatterBodyDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded(
+ objectActor.obj,
+ formatter.body
+ );
+ const body = formatterBodyDbgValue.call(
+ formatterBodyDbgValue.boundThis,
+ objectActor.obj,
+ customFormatterConfigDbgObj
+ );
+ if (body?.return?.class === "Array") {
+ const rawBody = body.return.unsafeDereference();
+ if (rawBody.length === 0) {
+ logCustomFormatterError(
+ globalWrapper,
+ `devtoolsFormatters[${customFormatterIndex}].body returned an empty array`,
+ formatterBodyDbgValue?.script
+ );
+ addInvalidCustomFormatterHooks(formatter);
+ return {
+ customFormatterBody: null,
+ };
+ }
+
+ const customFormatterBodyJsonMl =
+ buildJsonMlFromCustomFormatterHookResult(
+ body.return,
+ customFormatterObjectTagDepth,
+ targetActor
+ );
+
+ return {
+ customFormatterBody: customFormatterBodyJsonMl,
+ };
+ }
+
+ let errorMsg = "";
+ if (body == null) {
+ errorMsg = `devtoolsFormatters[${customFormatterIndex}].body was not run because it has side effects`;
+ } else if ("return" in body) {
+ let type = body.return === null ? "null" : typeof body.return;
+ if (type === "object") {
+ type = body.return?.class;
+ }
+ errorMsg = `devtoolsFormatters[${customFormatterIndex}].body should return an array, got ${type}`;
+ } else if ("throw" in body) {
+ errorMsg = `devtoolsFormatters[${customFormatterIndex}].body threw: ${
+ body.throw.getProperty("message")?.return
+ }`;
+ }
+
+ logCustomFormatterError(
+ globalWrapper,
+ errorMsg,
+ formatterBodyDbgValue?.script
+ );
+ addInvalidCustomFormatterHooks(formatter);
+ } catch (e) {
+ logCustomFormatterError(
+ globalWrapper,
+ `Custom formatter with index ${customFormatterIndex} couldn't be run: ${e.message}`
+ );
+ }
+
+ return {};
+}
+exports.customFormatterBody = customFormatterBody;
+
+/**
+ * Log an error caused by a fault in a custom formatter to the web console.
+ *
+ * @param {Window} window The related global where we should log this message.
+ * This should be the xray wrapper in order to expose windowGlobalChild.
+ * The unwrapped, unpriviledged won't expose this attribute.
+ * @param {string} errorMsg Message to log to the console.
+ * @param {DebuggerObject} [script] The script causing the error.
+ */
+function logCustomFormatterError(window, errorMsg, script) {
+ const scriptErrorClass = Cc["@mozilla.org/scripterror;1"];
+ const scriptError = scriptErrorClass.createInstance(Ci.nsIScriptError);
+ const { url, source, startLine, startColumn } = script ?? {};
+
+ scriptError.initWithWindowID(
+ `Custom formatter failed: ${errorMsg}`,
+ url,
+ source,
+ startLine,
+ startColumn,
+ Ci.nsIScriptError.errorFlag,
+ "devtoolsFormatter",
+ window.windowGlobalChild.innerWindowId
+ );
+ Services.console.logMessage(scriptError);
+}
+
+/**
+ * Return a ready to use JsonMl object, safe to be sent to the client.
+ * This will replace JsonMl items with object reference, e.g `[ "object", { config: ..., object: ... } ]`
+ * with objectActor grip or "regular" JsonMl items (e.g. `["span", {style: "color: red"}, "this is", "an object"]`)
+ * if the referenced object gets custom formatted as well.
+ *
+ * @param {DebuggerObject} jsonMlDbgObj: The debugger object representing a jsonMl object returned
+ * by a custom formatter hook.
+ * @param {Number} customFormatterObjectTagDepth: See `processObjectTag`.
+ * @param {BrowsingContextTargetActor} targetActor: The actor that will be managing any
+ * created ObjectActor.
+ * @returns {Array|null} Returns null if the passed object is a not DebuggerObject representing an Array
+ */
+function buildJsonMlFromCustomFormatterHookResult(
+ jsonMlDbgObj,
+ customFormatterObjectTagDepth,
+ targetActor
+) {
+ const tagName = jsonMlDbgObj.getProperty(0)?.return;
+ if (typeof tagName !== "string") {
+ const tagNameType =
+ tagName?.class || (tagName === null ? "null" : typeof tagName);
+ throw new Error(`tagName should be a string, got ${tagNameType}`);
+ }
+
+ // Fetch the other items of the jsonMl
+ const rest = [];
+ const dbgObjLength = jsonMlDbgObj.getProperty("length")?.return || 0;
+ for (let i = 1; i < dbgObjLength; i++) {
+ rest.push(jsonMlDbgObj.getProperty(i)?.return);
+ }
+
+ // The second item of the array can either be an object holding the attributes
+ // for the element or the first child element.
+ const attributesDbgObj =
+ rest[0] && rest[0].class === "Object" ? rest[0] : null;
+ const childrenDbgObj = attributesDbgObj ? rest.slice(1) : rest;
+
+ // If the tagName is "object", we need to replace the entry with the grip representing
+ // this object (that may or may not be custom formatted).
+ if (tagName == "object") {
+ if (!attributesDbgObj) {
+ throw new Error(`"object" tag should have attributes`);
+ }
+
+ // TODO: We could emit a warning if `childrenDbgObj` isn't empty as we're going to
+ // ignore them here.
+ return processObjectTag(
+ attributesDbgObj,
+ customFormatterObjectTagDepth,
+ targetActor
+ );
+ }
+
+ const jsonMl = [tagName, {}];
+ if (attributesDbgObj) {
+ // For non "object" tags, we only care about the style property
+ jsonMl[1].style = attributesDbgObj.getProperty("style")?.return;
+ }
+
+ // Handle children, which could be simple primitives or JsonML objects
+ for (const childDbgObj of childrenDbgObj) {
+ const childDbgObjType = typeof childDbgObj;
+ if (childDbgObj?.class === "Array") {
+ // `childDbgObj` probably holds a JsonMl item, sanitize it.
+ jsonMl.push(
+ buildJsonMlFromCustomFormatterHookResult(
+ childDbgObj,
+ customFormatterObjectTagDepth,
+ targetActor
+ )
+ );
+ } else if (childDbgObjType == "object" && childDbgObj !== null) {
+ // If we don't have an array, match Chrome implementation.
+ jsonMl.push("[object Object]");
+ } else {
+ // Here `childDbgObj` is a primitive. Create a grip so we can handle all the types
+ // we can stringify easily (e.g. `undefined`, `bigint`, …).
+ const grip = createValueGripForTarget(targetActor, childDbgObj);
+ if (grip !== null) {
+ jsonMl.push(grip);
+ }
+ }
+ }
+ return jsonMl;
+}
+
+/**
+ * Return a ready to use JsonMl object, safe to be sent to the client.
+ * This will replace JsonMl items with object reference, e.g `[ "object", { config: ..., object: ... } ]`
+ * with objectActor grip or "regular" JsonMl items (e.g. `["span", {style: "color: red"}, "this is", "an object"]`)
+ * if the referenced object gets custom formatted as well.
+ *
+ * @param {DebuggerObject} attributesDbgObj: The debugger object representing the "attributes"
+ * of a jsonMl item (e.g. the second item in the array).
+ * @param {Number} customFormatterObjectTagDepth: As "object" tag can reference custom
+ * formatted data, we track the number of time we go through this function
+ * from the "root" object so we don't have an infinite loop.
+ * @param {BrowsingContextTargetActor} targetActor: The actor that will be managin any
+ * created ObjectActor.
+ * @returns {Object} Returns a grip representing the underlying object
+ */
+function processObjectTag(
+ attributesDbgObj,
+ customFormatterObjectTagDepth,
+ targetActor
+) {
+ const objectDbgObj = attributesDbgObj.getProperty("object")?.return;
+ if (typeof objectDbgObj == "undefined") {
+ throw new Error(
+ `attribute of "object" tag should have an "object" property`
+ );
+ }
+
+ // We need to replace the "object" tag with the actual `attribute.object` object,
+ // which might be also custom formatted.
+ // We create the grip so the custom formatter hooks can be called on this object, or
+ // we'd get an object grip that we can consume to display an ObjectInspector on the client.
+ const configRv = attributesDbgObj.getProperty("config");
+ const grip = createValueGripForTarget(targetActor, objectDbgObj, 0, {
+ // Store the config so we can pass it when calling custom formatter hooks for this object.
+ customFormatterConfigDbgObj: configRv?.return,
+ customFormatterObjectTagDepth: (customFormatterObjectTagDepth || 0) + 1,
+ });
+
+ return grip;
+}
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..a7752b8201
--- /dev/null
+++ b/devtools/server/actors/utils/event-breakpoints.js
@@ -0,0 +1,508 @@
+/* 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";
+
+/**
+ *
+ * @param {String} groupID
+ * @param {String} eventType
+ * @param {Function} condition: Optional function that takes a Window as parameter. When
+ * passed, the event will only be included if the result of the function
+ * call is `true` (See `getAvailableEventBreakpoints`).
+ * @returns {Object}
+ */
+function generalEvent(groupID, eventType, condition) {
+ return {
+ id: `event.${groupID}.${eventType}`,
+ type: "event",
+ name: eventType,
+ message: `DOM '${eventType}' event`,
+ eventType,
+ filter: "general",
+ condition,
+ };
+}
+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: [
+ // The condition should be removed when "dom.element.popover.enabled" is removed
+ generalEvent("control", "beforetoggle", () =>
+ Services.prefs.getBoolPref("dom.element.popover.enabled")
+ ),
+ generalEvent("control", "blur"),
+ generalEvent("control", "change"),
+ generalEvent("control", "focus"),
+ generalEvent("control", "focusin"),
+ generalEvent("control", "focusout"),
+ // The condition should be removed when "dom.element.invokers.enabled" is removed
+ generalEvent("control", "invoke", win => "InvokeEvent" in win),
+ generalEvent("control", "reset"),
+ generalEvent("control", "resize"),
+ generalEvent("control", "scroll"),
+ generalEvent("control", "scrollend"),
+ generalEvent("control", "select"),
+ generalEvent("control", "toggle"),
+ generalEvent("control", "submit"),
+ generalEvent("control", "zoom"),
+ ],
+ },
+ {
+ 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: [
+ generalEvent("keyboard", "beforeinput"),
+ generalEvent("keyboard", "input"),
+ generalEvent("keyboard", "keydown"),
+ generalEvent("keyboard", "keyup"),
+ generalEvent("keyboard", "keypress"),
+ generalEvent("keyboard", "compositionstart"),
+ generalEvent("keyboard", "compositionupdate"),
+ generalEvent("keyboard", "compositionend"),
+ ].filter(Boolean),
+ },
+ {
+ name: "Load",
+ items: [
+ globalEvent("load", "load"),
+ 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;
+/**
+ * Get all available event breakpoints
+ *
+ * @param {Window} window
+ * @returns {Array<Object>} An array containing object with 2 properties, an id and a name,
+ * representing the event.
+ */
+function getAvailableEventBreakpoints(window) {
+ const available = [];
+ for (const { name, items } of AVAILABLE_BREAKPOINTS) {
+ available.push({
+ name,
+ events: items
+ .filter(item => !item.condition || item.condition(window))
+ .map(item => ({
+ id: item.id,
+ name: item.name,
+ })),
+ });
+ }
+ return available;
+}
+exports.validateEventBreakpoint = validateEventBreakpoint;
+function validateEventBreakpoint(id) {
+ return !!EVENTS_BY_ID[id];
+}
diff --git a/devtools/server/actors/utils/event-loop.js b/devtools/server/actors/utils/event-loop.js
new file mode 100644
index 0000000000..519d97ba7e
--- /dev/null
+++ b/devtools/server/actors/utils/event-loop.js
@@ -0,0 +1,221 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const xpcInspector = require("xpcInspector");
+
+/**
+ * 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.
+ */
+class EventLoop {
+ constructor({ thread }) {
+ this._thread = thread;
+
+ // A flag which is true in between the two calls to enter() and exit().
+ this._entered = false;
+ // Another flag which is true only after having called exit().
+ // Note that this EventLoop may still be paused and its enter() method
+ // still be on hold, if another EventLoop paused about this one.
+ this._resolved = false;
+ }
+
+ /**
+ * This is meant for other thread actors, and is used by other thread actor's
+ * EventLoop's isTheLastPausedThreadActor()
+ */
+ get thread() {
+ return this._thread;
+ }
+ /**
+ * Similarly, it will be used by another thread actor's EventLoop's enter() method
+ */
+ get resolved() {
+ return this._resolved;
+ }
+
+ /**
+ * Tells if the last thread actor to have paused (i.e. last EventLoop on the stack)
+ * is the current one.
+ *
+ * We avoid trying to exit this event loop,
+ * if another thread actor pile up a more recent one.
+ * All the event loops will be effectively exited when
+ * the thread actor which piled up the most recent nested event loop resumes.
+ *
+ * For convenience for the callsite, this will return true if nothing paused.
+ */
+ isTheLastPausedThreadActor() {
+ if (xpcInspector.eventLoopNestLevel > 0) {
+ return xpcInspector.lastNestRequestor.thread === this._thread;
+ }
+ return true;
+ }
+
+ /**
+ * Enter a new nested event loop.
+ */
+ enter() {
+ if (this._entered) {
+ throw new Error(
+ "Can't enter an event loop that has already been entered!"
+ );
+ }
+
+ const preEnterData = this.preEnter();
+
+ this._entered = true;
+ // Note: next line will synchronously block the execution until exit() is being called.
+ //
+ // This enterNestedEventLoop is a bit magical and will break run-to-completion rule of JS.
+ // JS will become multi-threaded. Some other task may start running on change state
+ // while we are blocked on this enterNestedEventLoop function call.
+ // You may find valuable information about Tasks and Event Loops on:
+ // https://docs.google.com/document/d/1jTMd-H_BwH9_QNUDxPse80vq884_hMvd234lvE5gqY8/edit?usp=sharing
+ //
+ // Note #2: this will update xpcInspector.lastNestRequestor to this
+ xpcInspector.enterNestedEventLoop(this);
+
+ // If this code runs, it means that we just exited this event loop and lastNestRequestor is no longer equal to this.
+ //
+ // We will now "recursively" exit all the resolved EventLoops which are blocked on `enterNestedEventLoop`:
+ // - if the new lastNestRequestor is resolved, request to exit it as well
+ // - this lastNestRequestor is another EventLoop instance
+ // - exiting this EventLoop unblocks its "enter" method and moves lastNestRequestor to the next requestor (if any)
+ // - we go back to the first step, and attempt to exit the new lastNestRequestor if it is resolved, etc...
+ if (xpcInspector.eventLoopNestLevel > 0) {
+ const { resolved } = xpcInspector.lastNestRequestor;
+ if (resolved) {
+ xpcInspector.exitNestedEventLoop();
+ }
+ }
+
+ this.postExit(preEnterData);
+ }
+
+ /**
+ * Exit 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 exited yet.
+ */
+ exit() {
+ if (!this._entered) {
+ throw new Error("Can't exit an event loop before it has been entered!");
+ }
+ this._entered = false;
+ this._resolved = true;
+
+ // If another ThreadActor paused and spawn a new nested event loop after this one,
+ // let it resume the thread and ignore this call.
+ // The code calling exitNestedEventLoop from EventLoop.enter will resume execution,
+ // by seeing that resolved attribute that we just toggled is true.
+ //
+ // Note that ThreadActor.resume method avoids calling exit thanks to `isTheLastPausedThreadActor`
+ // So for all use requests to resume, the ThreadActor won't call exit until it is the last
+ // thread actor to have entered a nested EventLoop.
+ 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;
+ }
+
+ // If EFT is enabled, accept any same process document (top-level or iframe).
+ if (this.thread.getParent().ignoreSubFrames) {
+ 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.
+ */
+ preEnter() {
+ 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.
+ */
+ postExit(pausedDocShells) {
+ // Enable events in all window paused in preEnter
+ 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.EventLoop = EventLoop;
diff --git a/devtools/server/actors/utils/gecko-profile-collector.js b/devtools/server/actors/utils/gecko-profile-collector.js
new file mode 100644
index 0000000000..1cdb6d7e56
--- /dev/null
+++ b/devtools/server/actors/utils/gecko-profile-collector.js
@@ -0,0 +1,285 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// The fallback color for unexpected cases
+const DEFAULT_COLOR = "grey";
+
+// The default category for unexpected cases
+const DEFAULT_CATEGORIES = [
+ {
+ name: "Mixed",
+ color: DEFAULT_COLOR,
+ subcategories: ["Other"],
+ },
+];
+
+// Color for each type of category/frame's implementation
+const PREDEFINED_COLORS = {
+ interpreter: "yellow",
+ baseline: "orange",
+ ion: "blue",
+ wasm: "purple",
+};
+
+/**
+ * Utility class that collects the JS tracer data and converts it to a Gecko
+ * profile object.
+ */
+class GeckoProfileCollector {
+ #thread = null;
+ #stackMap = new Map();
+ #frameMap = new Map();
+ #categories = DEFAULT_CATEGORIES;
+ #currentStack = [];
+ #time = 0;
+
+ /**
+ * Initialize the profiler and be ready to receive samples.
+ */
+ start() {
+ this.#reset();
+ this.#thread = this.#getEmptyThread();
+ }
+
+ /**
+ * Stop the record and return the gecko profiler data.
+ *
+ * @return {Object}
+ * The Gecko profile object.
+ */
+ stop() {
+ // Create the profile to return.
+ const profile = this.#getEmptyProfile();
+ profile.meta.categories = this.#categories;
+ profile.threads.push(this.#thread);
+
+ // Cleanup.
+ this.#reset();
+
+ return profile;
+ }
+
+ /**
+ * Clear all the internal state of this class.
+ */
+ #reset() {
+ this.#thread = null;
+ this.#stackMap = new Map();
+ this.#frameMap = new Map();
+ this.#categories = DEFAULT_CATEGORIES;
+ this.#currentStack = [];
+ this.#time = 0;
+ }
+
+ /**
+ * Initialize an empty Gecko profile object.
+ *
+ * @return {Object}
+ * Gecko profile object.
+ */
+ #getEmptyProfile() {
+ const httpHandler = Cc[
+ "@mozilla.org/network/protocol;1?name=http"
+ ].getService(Ci.nsIHttpProtocolHandler);
+ return {
+ meta: {
+ // Currently interval is 1, but we could change it to a lower number
+ // when we have durations coming from js tracer.
+ interval: 1,
+ startTime: 0,
+ product: Services.appinfo.name,
+ importedFrom: "JS Tracer",
+ version: 28,
+ presymbolicated: true,
+ abi: Services.appinfo.XPCOMABI,
+ misc: httpHandler.misc,
+ oscpu: httpHandler.oscpu,
+ platform: httpHandler.platform,
+ processType: Services.appinfo.processType,
+ categories: [],
+ stackwalk: 0,
+ toolkit: Services.appinfo.widgetToolkit,
+ appBuildID: Services.appinfo.appBuildID,
+ sourceURL: Services.appinfo.sourceURL,
+ physicalCPUs: 0,
+ logicalCPUs: 0,
+ CPUName: "",
+ markerSchema: [],
+ },
+ libs: [],
+ pages: [],
+ threads: [],
+ processes: [],
+ };
+ }
+
+ /**
+ * Generate a thread object to be stored in the Gecko profile object.
+ */
+ #getEmptyThread() {
+ return {
+ processType: "default",
+ processStartupTime: 0,
+ processShutdownTime: null,
+ registerTime: 0,
+ unregisterTime: null,
+ pausedRanges: [],
+ name: "GeckoMain",
+ "eTLD+1": "JS Tracer",
+ isMainThread: true,
+ pid: Services.appinfo.processID,
+ tid: 0,
+ samples: {
+ schema: {
+ stack: 0,
+ time: 1,
+ eventDelay: 2,
+ },
+ data: [],
+ },
+ markers: {
+ schema: {
+ name: 0,
+ startTime: 1,
+ endTime: 2,
+ phase: 3,
+ category: 4,
+ data: 5,
+ },
+ data: [],
+ },
+ stackTable: {
+ schema: {
+ prefix: 0,
+ frame: 1,
+ },
+ data: [],
+ },
+ frameTable: {
+ schema: {
+ location: 0,
+ relevantForJS: 1,
+ innerWindowID: 2,
+ implementation: 3,
+ line: 4,
+ column: 5,
+ category: 6,
+ subcategory: 7,
+ },
+ data: [],
+ },
+ stringTable: [],
+ };
+ }
+
+ /**
+ * Record a new sample to be stored in the Gecko profile object.
+ *
+ * @param {Object} frame
+ * Object describing a frame with following attributes:
+ * - {String} name
+ * Human readable name for this frame.
+ * - {String} url
+ * URL of the running script.
+ * - {Number} lineNumber
+ * Line currently executing for this script.
+ * - {Number} columnNumber
+ * Column currently executing for this script.
+ * - {String} category
+ * Which JS implementation is being used for this frame: interpreter, baseline, ion or wasm.
+ * See Debugger.frame.implementation.
+ */
+ addSample(frame, depth) {
+ const currentDepth = this.#currentStack.length;
+ if (currentDepth == depth) {
+ // We are in the same depth and executing another frame. Replace the
+ // current frame with the new one.
+ this.#currentStack[currentDepth] = frame;
+ } else if (currentDepth < depth) {
+ // We are going deeper in the stack. Push the new frame.
+ this.#currentStack.push(frame);
+ } else {
+ // We are going back in the stack. Pop frames until we reach the right depth.
+ this.#currentStack.length = depth;
+ this.#currentStack[depth] = frame;
+ }
+
+ const stack = this.#currentStack.reduce((prefix, stackFrame) => {
+ const frameIndex = this.#getOrCreateFrame(stackFrame);
+ return this.#getOrCreateStack(frameIndex, prefix);
+ }, null);
+ this.#thread.samples.data.push([
+ stack,
+ // We put simply 1 sample (1ms) for each frame. We can change it in the
+ // future if we can get the duration of the frame.
+ this.#time++,
+ 0, // eventDelay
+ ]);
+ }
+
+ #getOrCreateFrame(frame) {
+ const { frameTable, stringTable } = this.#thread;
+ const frameString = `${frame.name}:${frame.url}:${frame.lineNumber}:${frame.columnNumber}:${frame.category}`;
+ let frameIndex = this.#frameMap.get(frameString);
+
+ if (frameIndex === undefined) {
+ frameIndex = frameTable.data.length;
+ const location = stringTable.length;
+ // Profiler frontend except a particular string to match the source URL:
+ // `functionName (http://script.url/:1234:1234)`
+ // https://github.com/firefox-devtools/profiler/blob/dab645b2db7e1b21185b286f96dd03b77f68f5c3/src/profile-logic/process-profile.js#L518
+ stringTable.push(
+ `${frame.name} (${frame.url}:${frame.lineNumber}:${frame.columnNumber})`
+ );
+
+ const category = this.#getOrCreateCategory(frame.category);
+
+ frameTable.data.push([
+ location,
+ true, // relevantForJS
+ 0, // innerWindowID
+ null, // implementation
+ frame.lineNumber, // line
+ frame.columnNumber, // column
+ category,
+ 0, // subcategory
+ ]);
+ this.#frameMap.set(frameString, frameIndex);
+ }
+
+ return frameIndex;
+ }
+
+ #getOrCreateStack(frameIndex, prefix) {
+ const { stackTable } = this.#thread;
+ const key = prefix === null ? `${frameIndex}` : `${frameIndex},${prefix}`;
+ let stack = this.#stackMap.get(key);
+
+ if (stack === undefined) {
+ stack = stackTable.data.length;
+ stackTable.data.push([prefix, frameIndex]);
+ this.#stackMap.set(key, stack);
+ }
+ return stack;
+ }
+
+ #getOrCreateCategory(category) {
+ const categories = this.#categories;
+ let categoryIndex = categories.findIndex(c => c.name === category);
+
+ if (categoryIndex === -1) {
+ categoryIndex = categories.length;
+ categories.push({
+ name: category,
+ color: PREDEFINED_COLORS[category] ?? DEFAULT_COLOR,
+ subcategories: ["Other"],
+ });
+ }
+ return categoryIndex;
+ }
+}
+
+exports.GeckoProfileCollector = GeckoProfileCollector;
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..759c2e6215
--- /dev/null
+++ b/devtools/server/actors/utils/inactive-property-helper.js
@@ -0,0 +1,1443 @@
+/* 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,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+
+const INACTIVE_CSS_ENABLED = Services.prefs.getBoolPref(
+ "devtools.inspector.inactive.css.enabled",
+ false
+);
+
+const TEXT_WRAP_BALANCE_LIMIT = Services.prefs.getIntPref(
+ "layout.css.text-wrap-balance.limit",
+ 10
+);
+
+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",
+]);
+
+// Set of node names which are always treated as replaced elements:
+const REPLACED_ELEMENTS_NAMES = new Set([
+ "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",
+]);
+
+const CUE_PSEUDO_ELEMENT_STYLING_SPEC_URL =
+ "https://developer.mozilla.org/docs/Web/CSS/::cue";
+
+const HIGHLIGHT_PSEUDO_ELEMENTS_STYLING_SPEC_URL =
+ "https://www.w3.org/TR/css-pseudo-4/#highlight-styling";
+const HIGHLIGHT_PSEUDO_ELEMENTS = [
+ "::highlight",
+ "::selection",
+ // Below are properties not yet implemented in Firefox (Bug 1694053)
+ "::grammar-error",
+ "::spelling-error",
+ "::target-text",
+];
+const REGEXP_HIGHLIGHT_PSEUDO_ELEMENTS = new RegExp(
+ `${HIGHLIGHT_PSEUDO_ELEMENTS.join("|")}`
+);
+
+const FIRST_LINE_PSEUDO_ELEMENT_STYLING_SPEC_URL =
+ "https://www.w3.org/TR/css-pseudo-4/#first-line-styling";
+
+const FIRST_LETTER_PSEUDO_ELEMENT_STYLING_SPEC_URL =
+ "https://www.w3.org/TR/css-pseudo-4/#first-letter-styling";
+
+const PLACEHOLDER_PSEUDO_ELEMENT_STYLING_SPEC_URL =
+ "https://www.w3.org/TR/css-pseudo-4/#placeholder-pseudo";
+
+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:
+ * Set 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.
+ * }
+ *
+ * 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.
+ *
+ * NOTE: We should generally *not* add rules here for any CSS properties that
+ * inherit by default, because it's hard for us to know whether such
+ * properties are truly "inactive". Web developers might legitimately set
+ * such a property on any arbitrary element, in order to concisely establish
+ * the default property-value throughout that element's subtree. For example,
+ * consider the "list-style-*" properties, which inherit by default and which
+ * only have a rendering effect on elements with "display:list-item"
+ * (e.g. <li>). It might superficially seem like we could add a rule here to
+ * warn about usages of these properties on non-"list-item" elements, but we
+ * shouldn't actually warn about that. A web developer may legitimately
+ * prefer to set these properties on an arbitrary container element (e.g. an
+ * <ol> element, or even the <html> element) in order to concisely adjust the
+ * rendering of a whole list (or all the lists in a document).
+ */
+ get INVALID_PROPERTIES_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",
+ },
+ // 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",
+ },
+ // Grid container property used on non-grid container.
+ {
+ invalidProperties: [
+ "grid-auto-columns",
+ "grid-auto-flow",
+ "grid-auto-rows",
+ "grid-template",
+ "grid-template-areas",
+ "grid-template-columns",
+ "grid-template-rows",
+ "justify-items",
+ ],
+ when: () => !this.gridContainer,
+ fixId: "inactive-css-not-grid-container-fix",
+ msgId: "inactive-css-not-grid-container",
+ },
+ // 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",
+ },
+ // 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-3",
+ msgId: "inactive-css-not-grid-or-flex-item",
+ },
+ // 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-row-gap",
+ ],
+ when: () => !this.gridContainer && !this.flexContainer,
+ fixId: "inactive-css-not-grid-or-flex-container-fix",
+ msgId: "inactive-css-not-grid-or-flex-container",
+ },
+ // 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",
+ },
+ // column-gap and shorthands used on non-grid or non-flex or non-multi-col container.
+ {
+ invalidProperties: [
+ "column-gap",
+ "gap",
+ "grid-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",
+ },
+ // Multi-column related properties used on non-multi-column container.
+ {
+ invalidProperties: [
+ "column-fill",
+ "column-rule",
+ "column-rule-color",
+ "column-rule-style",
+ "column-rule-width",
+ ],
+ when: () => !this.multiColContainer,
+ fixId: "inactive-css-not-multicol-container-fix",
+ msgId: "inactive-css-not-multicol-container",
+ },
+ // Inline properties used on non-inline-level elements.
+ {
+ invalidProperties: ["vertical-align"],
+ when: () =>
+ !this.isInlineLevel() && !this.isFirstLetter && !this.isFirstLine,
+ fixId: "inactive-css-not-inline-or-tablecell-fix",
+ msgId: "inactive-css-not-inline-or-tablecell",
+ },
+ // Writing mode properties used on ::first-line pseudo-element.
+ {
+ invalidProperties: ["direction", "text-orientation", "writing-mode"],
+ when: () => this.isFirstLine,
+ fixId: "learn-more",
+ msgId: "inactive-css-first-line-pseudo-element-not-supported",
+ learnMoreURL: FIRST_LINE_PSEUDO_ELEMENT_STYLING_SPEC_URL,
+ },
+ // Content modifying properties used on ::first-letter pseudo-element.
+ {
+ invalidProperties: ["content"],
+ when: () => this.isFirstLetter,
+ fixId: "learn-more",
+ msgId: "inactive-css-first-letter-pseudo-element-not-supported",
+ learnMoreURL: FIRST_LETTER_PSEUDO_ELEMENT_STYLING_SPEC_URL,
+ },
+ // Writing mode or inline properties used on ::placeholder pseudo-element.
+ {
+ invalidProperties: [
+ "baseline-source",
+ "direction",
+ "dominant-baseline",
+ "line-height",
+ "text-orientation",
+ "vertical-align",
+ "writing-mode",
+ // Below are properties not yet implemented in Firefox (Bug 1312611)
+ "alignment-baseline",
+ "baseline-shift",
+ "initial-letter",
+ "text-box-trim",
+ ],
+ when: () => {
+ const { selectorText } = this.cssRule;
+ return selectorText && selectorText.includes("::placeholder");
+ },
+ fixId: "learn-more",
+ msgId: "inactive-css-placeholder-pseudo-element-not-supported",
+ learnMoreURL: PLACEHOLDER_PSEUDO_ELEMENT_STYLING_SPEC_URL,
+ },
+ // (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",
+ },
+ // (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",
+ },
+ {
+ 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",
+ },
+ // 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",
+ 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",
+ },
+ // 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",
+ },
+ // text-overflow property used on elements for which 'overflow' is set to 'visible'
+ // (the initial value) in the inline axis. Note that this validator only checks if
+ // 'overflow-inline' computes to 'visible' 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-inline: visible' was 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-inline", ["visible"]),
+ fixId: "inactive-text-overflow-when-no-overflow-fix",
+ msgId: "inactive-text-overflow-when-no-overflow",
+ },
+ // 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",
+ },
+ // 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",
+ },
+ // table-layout used on non-table elements.
+ {
+ invalidProperties: ["table-layout"],
+ when: () =>
+ !this.checkComputedStyle("display", ["table", "inline-table"]),
+ fixId: "inactive-css-not-table-fix",
+ msgId: "inactive-css-not-table",
+ },
+ // empty-cells property used on non-table-cell elements.
+ {
+ invalidProperties: ["empty-cells"],
+ when: () => !this.checkComputedStyle("display", ["table-cell"]),
+ fixId: "inactive-css-not-table-cell-fix",
+ msgId: "inactive-css-not-table-cell",
+ },
+ // scroll-padding-* properties used on non-scrollable elements.
+ {
+ invalidProperties: [
+ "scroll-padding",
+ "scroll-padding-top",
+ "scroll-padding-right",
+ "scroll-padding-bottom",
+ "scroll-padding-left",
+ "scroll-padding-block",
+ "scroll-padding-block-end",
+ "scroll-padding-block-start",
+ "scroll-padding-inline",
+ "scroll-padding-inline-end",
+ "scroll-padding-inline-start",
+ ],
+ when: () => !this.isScrollContainer,
+ fixId: "inactive-scroll-padding-when-not-scroll-container-fix",
+ msgId: "inactive-scroll-padding-when-not-scroll-container",
+ },
+ // border-image properties used on internal table with border collapse.
+ {
+ invalidProperties: [
+ "border-image",
+ "border-image-outset",
+ "border-image-repeat",
+ "border-image-slice",
+ "border-image-source",
+ "border-image-width",
+ ],
+ when: () =>
+ this.internalTableElement &&
+ this.checkTableParentHasBorderCollapsed(),
+ fixId: "inactive-css-border-image-fix",
+ msgId: "inactive-css-border-image",
+ },
+ // width & height properties used on ruby elements.
+ {
+ invalidProperties: [
+ "height",
+ "min-height",
+ "max-height",
+ "width",
+ "min-width",
+ "max-width",
+ ],
+ when: () => this.checkComputedStyle("display", ["ruby", "ruby-text"]),
+ fixId: "inactive-css-ruby-element-fix",
+ msgId: "inactive-css-ruby-element",
+ },
+ // text-wrap: balance; used on elements exceeding the threshold line number
+ {
+ invalidProperties: ["text-wrap"],
+ when: () => {
+ if (!this.checkComputedStyle("text-wrap", ["balance"])) {
+ return false;
+ }
+ const blockLineCounts = InspectorUtils.getBlockLineCounts(this.node);
+ // We only check the number of lines within the first block
+ // because the text-wrap: balance; property only applies to
+ // the first block. And fragmented elements (with multiple
+ // blocks) are excluded from line balancing for the time being.
+ return (
+ blockLineCounts && blockLineCounts[0] > TEXT_WRAP_BALANCE_LIMIT
+ );
+ },
+ fixId: "inactive-css-text-wrap-balance-lines-exceeded-fix",
+ msgId: "inactive-css-text-wrap-balance-lines-exceeded",
+ lineCount: TEXT_WRAP_BALANCE_LIMIT,
+ },
+ // text-wrap: balance; used on fragmented elements
+ {
+ invalidProperties: ["text-wrap"],
+ when: () => {
+ if (!this.checkComputedStyle("text-wrap", ["balance"])) {
+ return false;
+ }
+ const blockLineCounts = InspectorUtils.getBlockLineCounts(this.node);
+ const isFragmented = blockLineCounts && blockLineCounts.length > 1;
+ return isFragmented;
+ },
+ fixId: "inactive-css-text-wrap-balance-fragmented-fix",
+ msgId: "inactive-css-text-wrap-balance-fragmented",
+ },
+ ];
+ }
+
+ /**
+ * A list of rules for when CSS properties have no effect,
+ * based on an allow list of properties.
+ * We're setting this as a different array than INVALID_PROPERTIES_VALIDATORS as we
+ * need to check every properties, which we don't do for invalid properties ( see check
+ * on this.invalidProperties).
+ *
+ * This file contains "rules" in the form of objects with the following
+ * properties:
+ * {
+ * acceptedProperties:
+ * Array of CSS property names that are the only one accepted 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.
+ * }
+ *
+ * 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.
+ */
+ ACCEPTED_PROPERTIES_VALIDATORS = [
+ // Constrained set of properties on highlight pseudo-elements
+ {
+ acceptedProperties: new Set([
+ // At the moment, for shorthand we don't look into each properties it covers,
+ // and so, although `background` might hold inactive values (e.g. background-image)
+ // we don't want to mark it as inactive if it sets a background-color (e.g. background: red).
+ "background",
+ "background-color",
+ "color",
+ "text-decoration",
+ "text-decoration-color",
+ "text-decoration-line",
+ "text-decoration-style",
+ "text-decoration-thickness",
+ "text-shadow",
+ "text-underline-offset",
+ "text-underline-position",
+ "-webkit-text-fill-color",
+ "-webkit-text-stroke-color",
+ "-webkit-text-stroke-width",
+ "-webkit-text-stroke",
+ ]),
+ when: () => {
+ const { selectorText } = this.cssRule;
+ return (
+ selectorText && REGEXP_HIGHLIGHT_PSEUDO_ELEMENTS.test(selectorText)
+ );
+ },
+ msgId: "inactive-css-highlight-pseudo-elements-not-supported",
+ fixId: "learn-more",
+ learnMoreURL: HIGHLIGHT_PSEUDO_ELEMENTS_STYLING_SPEC_URL,
+ },
+ // Constrained set of properties on ::cue pseudo-element
+ //
+ // Note that Gecko doesn't yet support the ::cue() pseudo-element
+ // taking a selector as argument. The properties accecpted by that
+ // partly differ from the ones accepted by the ::cue pseudo-element.
+ // See https://w3c.github.io/webvtt/#ref-for-selectordef-cue-selector⑧.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=865395 and its
+ // dependencies for the implementation status.
+ {
+ acceptedProperties: new Set([
+ "background",
+ "background-attachment",
+ // The WebVTT spec. currently only allows all properties covered by
+ // the `background` shorthand and `background-blend-mode` is not
+ // part of that, though Gecko does support it, anyway.
+ // Therefore, there's also an issue pending to add it (and others)
+ // to the spec. See https://github.com/w3c/webvtt/issues/518.
+ "background-blend-mode",
+ "background-clip",
+ "background-color",
+ "background-image",
+ "background-origin",
+ "background-position",
+ "background-position-x",
+ "background-position-y",
+ "background-repeat",
+ "background-size",
+ "color",
+ "font",
+ "font-family",
+ "font-size",
+ "font-stretch",
+ "font-style",
+ "font-variant",
+ "font-variant-alternates",
+ "font-variant-caps",
+ "font-variant-east-asian",
+ "font-variant-ligatures",
+ "font-variant-numeric",
+ "font-variant-position",
+ "font-weight",
+ "line-height",
+ "opacity",
+ "outline",
+ "outline-color",
+ "outline-offset",
+ "outline-style",
+ "outline-width",
+ "ruby-position",
+ "text-combine-upright",
+ "text-decoration",
+ "text-decoration-color",
+ "text-decoration-line",
+ "text-decoration-style",
+ "text-decoration-thickness",
+ "text-shadow",
+ "visibility",
+ "white-space",
+ ]),
+ when: () => {
+ const { selectorText } = this.cssRule;
+ return selectorText && selectorText.includes("::cue");
+ },
+ msgId: "inactive-css-cue-pseudo-element-not-supported",
+ fixId: "learn-more",
+ learnMoreURL: CUE_PSEUDO_ELEMENT_STYLING_SPEC_URL,
+ },
+ ];
+
+ /**
+ * 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.INVALID_PROPERTIES_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 {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
+ if (!INACTIVE_CSS_ENABLED) {
+ return { used: true };
+ }
+
+ let fixId = "";
+ let msgId = "";
+ let learnMoreURL = null;
+ let lineCount = null;
+ let used = true;
+
+ const someFn = validator => {
+ // First check if this rule cares about this property.
+ let isRuleConcerned = false;
+
+ if (validator.invalidProperties) {
+ isRuleConcerned = validator.invalidProperties.includes(property);
+ } else if (validator.acceptedProperties) {
+ isRuleConcerned = !validator.acceptedProperties.has(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;
+ learnMoreURL = validator.learnMoreURL;
+ lineCount = validator.lineCount;
+ used = false;
+
+ // We can bail out as soon as a validator reported an issue.
+ return true;
+ }
+
+ return false;
+ };
+
+ // First run the accepted properties validators
+ const isNotAccepted = this.ACCEPTED_PROPERTIES_VALIDATORS.some(someFn);
+
+ // If the property is not in the list of properties to check and there was no issues
+ // in the accepted properties validators, assume the property is used.
+ if (!isNotAccepted && !this.invalidProperties.has(property)) {
+ this.unselect();
+ return { used: true };
+ }
+
+ // Otherwise, if there was no issue from the accepted properties validators,
+ // run the invalid properties validators.
+ if (!isNotAccepted) {
+ this.INVALID_PROPERTIES_VALIDATORS.some(someFn);
+ }
+
+ 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,
+ property,
+ learnMoreURL,
+ lineCount,
+ 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 tableTrackParent = this.getTableTrackParent();
+
+ return this.hasVerticalWritingMode(tableTrackParent)
+ ? 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 tableTrackParent = this.getTableTrackParent();
+
+ return this.hasVerticalWritingMode(tableTrackParent)
+ ? 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 tableTrackParent = this.getTableTrackParent(true);
+ const isVertical = this.hasVerticalWritingMode(tableTrackParent);
+
+ 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 tableTrackParent = this.getTableTrackParent(true);
+ const isVertical = this.hasVerticalWritingMode(tableTrackParent);
+
+ 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 selector refers to a ::first-letter pseudo-element
+ */
+ get isFirstLetter() {
+ const { selectorText } = this.cssRule;
+ return selectorText && selectorText.includes("::first-letter");
+ }
+
+ /**
+ * Check if the current selector refers to a ::first-line pseudo-element
+ */
+ get isFirstLine() {
+ const { selectorText } = this.cssRule;
+ return selectorText && selectorText.includes("::first-line");
+ }
+
+ /**
+ * 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 scrollable
+ */
+ get isScrollContainer() {
+ // If `overflow` doesn't contain the values `visible` or `clip`, it is a scroll container.
+ // While `hidden` doesn't allow scrolling via a user interaction, the element can
+ // still be scrolled programmatically.
+ // See https://www.w3.org/TR/css-overflow-3/#overflow-properties.
+ const overflow = computedStyle(this.node).overflow;
+ // `overflow` is a shorthand for `overflow-x` and `overflow-y`
+ // (and with that also for `overflow-inline` and `overflow-block`),
+ // so may hold two values.
+ const overflowValues = overflow.split(" ");
+ return !(
+ overflowValues.includes("visible") || overflowValues.includes("clip")
+ );
+ }
+
+ /**
+ * 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() {
+ if (REPLACED_ELEMENTS_NAMES.has(this.localName)) {
+ return true;
+ }
+
+ // img tags are replaced elements only when the image has finished loading.
+ if (this.localName === "img" && this.node.complete) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Return the current node's localName.
+ *
+ * @returns {String}
+ */
+ get localName() {
+ return this.node.localName;
+ }
+
+ /**
+ * 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 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") &&
+ this.cssRule.selectorMatchesElement(i, bindingElement, 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";
+ }
+
+ /**
+ * Check if the given node's writing mode is vertical
+ */
+ hasVerticalWritingMode(node) {
+ // Only 'horizontal-tb' has a horizontal writing mode.
+ // See https://drafts.csswg.org/css-writing-modes-4/#propdef-writing-mode
+ return computedStyle(node).writingMode !== "horizontal-tb";
+ }
+
+ /**
+ * Assuming the current element is a table track (row or column) or table track group,
+ * get the parent table.
+ * 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 the current element.
+ *
+ * @param {Boolean} isGroup
+ * Whether the element is a table track group, instead of a table track.
+ * @return {DOMNode}
+ * The parent table, the parent element, or the element itself.
+ */
+ getTableTrackParent(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 current;
+ }
+
+ /**
+ * Get the parent table element of the current element.
+ *
+ * @return {DOMNode|null}
+ * The closest table element or null if there are none.
+ */
+ getTableParent() {
+ let current = this.node.parentNode;
+
+ // Find the table parent
+ while (current && computedStyle(current).display !== "table") {
+ current = current.parentNode;
+
+ // If we reached the document element, stop.
+ if (current == this.node.ownerDocument.documentElement) {
+ return null;
+ }
+ }
+
+ return current;
+ }
+
+ /**
+ * Assuming the current element is an internal table element,
+ * check wether its parent table element has `border-collapse` set to `collapse`.
+ *
+ * @returns {Boolean}
+ */
+ checkTableParentHasBorderCollapsed() {
+ const parent = this.getTableParent();
+ if (!parent) {
+ return false;
+ }
+ return computedStyle(parent).borderCollapse === "collapse";
+ }
+}
+
+/**
+ * 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);
+}
+
+const inactivePropertyHelper = new InactivePropertyHelper();
+
+// The only public method from this module is `isPropertyUsed`.
+exports.isPropertyUsed = inactivePropertyHelper.isPropertyUsed.bind(
+ inactivePropertyHelper
+);
diff --git a/devtools/server/actors/utils/logEvent.js b/devtools/server/actors/utils/logEvent.js
new file mode 100644
index 0000000000..88b166619e
--- /dev/null
+++ b/devtools/server/actors/utils/logEvent.js
@@ -0,0 +1,112 @@
+/* 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("resource://devtools/server/actors/frame.js");
+const {
+ TYPES,
+ getResourceWatcher,
+} = require("resource://devtools/server/actors/resources/index.js");
+
+// 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,
+ disableBreaks: true,
+ });
+
+ return undefined;
+ }
+
+ let completion;
+ // Ensure disabling all types of breakpoints for all sources while evaluating the log points
+ threadActor.insideClientEvaluation = { disableBreaks: true };
+ try {
+ completion = frame.evalWithBindings(
+ expression,
+ {
+ displayName,
+ ...bindings,
+ },
+ { hideFromDebugger: true }
+ );
+ } finally {
+ threadActor.insideClientEvaluation = null;
+ }
+
+ 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 targetActor = threadActor._parent;
+ const message = {
+ filename: sourceActor.url,
+ lineNumber: line,
+ columnNumber: column,
+ arguments: value,
+ level,
+ timeStamp: ChromeUtils.dateNow(),
+ chromeContext:
+ targetActor.actorID &&
+ /conn\d+\.parentProcessTarget\d+/.test(targetActor.actorID),
+ // The 'prepareConsoleMessageForRemote' method in webconsoleActor expects internal source ID,
+ // thus we can't set sourceId directly to sourceActorID.
+ sourceId: sourceActor.internalSourceId,
+ };
+
+ // Note that only WindowGlobalTarget actor support resource watcher
+ // This is still missing for worker and content processes
+ const consoleMessageWatcher = getResourceWatcher(
+ targetActor,
+ TYPES.CONSOLE_MESSAGE
+ );
+ if (consoleMessageWatcher) {
+ consoleMessageWatcher.emitMessages([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..40c28f01b2
--- /dev/null
+++ b/devtools/server/actors/utils/make-debugger.js
@@ -0,0 +1,122 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const Debugger = require("Debugger");
+
+const {
+ reportException,
+} = require("resource://devtools/shared/DevToolsUtils.js");
+
+/**
+ * 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 are the actual global objects and aren't wrapped
+ * in in a |Debugger.Object|.
+ *
+ * @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);
+
+ // By default, we disable asm.js and WASM debugging because of performance reason.
+ // Enabling asm.js debugging (allowUnobservedAsmJS=false) will make asm.js fallback to JS compiler
+ // and be debugging as a regular JS script.
+ dbg.allowUnobservedAsmJS = true;
+ // Enabling WASM debugging (allowUnobservedWasm=false) will make the engine compile WASM scripts
+ // into different machine code with debugging instructions. This significantly increase the memory usage of it.
+ dbg.allowUnobservedWasm = 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;
+ };
+ dbg.findDebuggees = function () {
+ return findDebuggees(dbg);
+ };
+
+ 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..405b25cb4b
--- /dev/null
+++ b/devtools/server/actors/utils/moz.build
@@ -0,0 +1,32 @@
+# -*- 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",
+ "custom-formatters.js",
+ "dbg-source.js",
+ "event-breakpoints.js",
+ "event-loop.js",
+ "gecko-profile-collector.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",
+ "stylesheet-utils.js",
+ "stylesheets-manager.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..fccd0d67bf
--- /dev/null
+++ b/devtools/server/actors/utils/source-map-utils.js
@@ -0,0 +1,42 @@
+/* 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.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..be80025e46
--- /dev/null
+++ b/devtools/server/actors/utils/source-url.js
@@ -0,0 +1,44 @@
+/* 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;
+ }
+ // When using <iframe srcdoc="<script> js source </script>"/>, we can't easily fetch the srcdoc
+ // full html text content. So, consider each inline script as independant source with
+ // their own URL. Thus the ID appended to each URL.
+ if (source.url == "about:srcdoc") {
+ return source.url + "#" + source.id;
+ }
+
+ 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..b80da69bfa
--- /dev/null
+++ b/devtools/server/actors/utils/sources-manager.js
@@ -0,0 +1,515 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+const { assert, fetch } = DevToolsUtils;
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const {
+ SourceLocation,
+} = require("resource://devtools/server/actors/common.js");
+
+loader.lazyRequireGetter(
+ this,
+ "SourceActor",
+ "resource://devtools/server/actors/source.js",
+ 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) {
+ super();
+ this._thread = threadActor;
+
+ 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.
+ */
+ createSourceActor(source) {
+ assert(source, "SourcesManager.prototype.source needs a source");
+
+ 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);
+ // NOTE: Debugger.Source.prototype.startColumn is 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = script.format === "wasm" ? 0 : 1;
+ return new SourceLocation(
+ this.createSourceActor(script.source),
+ lineNumber,
+ columnNumber - columnBase
+ );
+ }
+
+ /**
+ * 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) {
+ if (!this.blackBoxedSources.has(url)) {
+ return false;
+ }
+
+ const ranges = this.blackBoxedSources.get(url);
+
+ // If we have an entry in the map, but it is falsy, the source is fully blackboxed.
+ if (!ranges) {
+ return true;
+ }
+
+ 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);
+ }
+
+ clearAllBlackBoxing() {
+ this.blackBoxedSources.clear();
+ }
+
+ /**
+ * 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)) {
+ // We received many devtools-html-content events, if we already received one,
+ // aggregate the data with the one we already received.
+ 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 if (contents) {
+ // Ensure that `contents` is non-empty. We may miss all the devtools-html-content events except the last
+ // one which has a empty `contents` and complete set to true.
+ // This reproduces when opening a same-process iframe. In this particular scenario, we instantiate the target and thread actor
+ // on `DOMDocElementInserted` and the HTML document is already parsed, but we still receive this one very last notification.
+ 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,
+ };
+ }
+ if (partial) {
+ return {
+ content: "",
+ 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.browsingContext) {
+ loadFromCache = !(
+ this._thread._parent.browsingContext.defaultLoadFlags ===
+ Ci.nsIRequest.LOAD_BYPASS_CACHE
+ );
+ }
+
+ // 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
+ }
+ }
+}
+
+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;
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..5f2e912002
--- /dev/null
+++ b/devtools/server/actors/utils/style-utils.js
@@ -0,0 +1,211 @@
+/* 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("resource://devtools/shared/css/lexer.js");
+
+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;
+// Factor used to resize the canvas in order to get better text quality.
+const FONT_PREVIEW_OVERSAMPLING_FACTOR = 2;
+
+/**
+ * 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 previewTextLines = previewText.split("\n");
+ 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 previewTextLinesWidths = previewTextLines.map(
+ previewTextLine => ctx.measureText(previewTextLine).width
+ );
+ const textWidth = Math.round(Math.max(...previewTextLinesWidths));
+
+ // The canvas width is calculated as the width of the longest line plus
+ // an offset at the left and right of it.
+ // The canvas height is calculated as the font size multiplied by the
+ // number of lines plus an offset at the top and bottom.
+ //
+ // In order to get better text quality, we oversample the canvas.
+ // That means, after the width and height are calculated, we increase
+ // both sizes by some factor.
+ const simpleCanvasWidth = textWidth + FONT_PREVIEW_OFFSET * 2;
+ canvas.width = simpleCanvasWidth * FONT_PREVIEW_OVERSAMPLING_FACTOR;
+ canvas.height =
+ (previewFontSize * previewTextLines.length + FONT_PREVIEW_OFFSET * 2) *
+ FONT_PREVIEW_OVERSAMPLING_FACTOR;
+
+ // we have to reset these after changing the canvas size
+ ctx.font = fontValue;
+ ctx.fillStyle = fillStyle;
+
+ // Oversample the canvas for better text quality
+ ctx.scale(FONT_PREVIEW_OVERSAMPLING_FACTOR, FONT_PREVIEW_OVERSAMPLING_FACTOR);
+
+ ctx.textBaseline = "top";
+ ctx.textAlign = "center";
+ const horizontalTextPosition = simpleCanvasWidth / 2;
+ let verticalTextPosition = FONT_PREVIEW_OFFSET;
+ for (let i = 0; i < previewTextLines.length; i++) {
+ ctx.fillText(
+ previewTextLines[i],
+ horizontalTextPosition,
+ verticalTextPosition
+ );
+
+ // Move vertical text position one line down
+ verticalTextPosition += previewFontSize;
+ }
+
+ const dataURL = canvas.toDataURL("image/png");
+
+ return {
+ 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, text: text.substr(offset) };
+}
+
+exports.getTextAtLineColumn = getTextAtLineColumn;
diff --git a/devtools/server/actors/utils/stylesheet-utils.js b/devtools/server/actors/utils/stylesheet-utils.js
new file mode 100644
index 0000000000..682a752c3d
--- /dev/null
+++ b/devtools/server/actors/utils/stylesheet-utils.js
@@ -0,0 +1,155 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { fetch } = require("resource://devtools/shared/DevToolsUtils.js");
+
+/**
+ * For imported stylesheets, `ownerNode` is null.
+ *
+ * To resolve the ownerNode for an imported stylesheet, loop on `parentStylesheet`
+ * until we reach the topmost stylesheet, which should have a valid ownerNode.
+ *
+ * Constructable stylesheets do not have an owner node and this method will
+ * return null.
+ *
+ * @param {StyleSheet}
+ * The stylesheet for which we want to retrieve the ownerNode.
+ * @return {DOMNode|null} The ownerNode or null for constructable stylesheets.
+ */
+function getStyleSheetOwnerNode(sheet) {
+ // If this is not an imported stylesheet and we have an ownerNode available
+ // bail out immediately.
+ if (sheet.ownerNode) {
+ return sheet.ownerNode;
+ }
+
+ let parentStyleSheet = sheet;
+ while (
+ parentStyleSheet.parentStyleSheet &&
+ parentStyleSheet !== parentStyleSheet.parentStyleSheet
+ ) {
+ parentStyleSheet = parentStyleSheet.parentStyleSheet;
+ }
+
+ return parentStyleSheet.ownerNode;
+}
+
+exports.getStyleSheetOwnerNode = getStyleSheetOwnerNode;
+
+/**
+ * Get the text of a stylesheet.
+ *
+ * TODO: A call site in window-global.js expects this method to return a promise
+ * so it is mandatory to keep it as an async function even if we are not using
+ * await explicitly. Bug 1810572.
+ *
+ * @param {StyleSheet}
+ * The stylesheet for which we want to retrieve the text.
+ * @returns {Promise}
+ */
+async function getStyleSheetText(styleSheet) {
+ if (!styleSheet.href) {
+ if (styleSheet.ownerNode) {
+ // this is an inline <style> sheet
+ return styleSheet.ownerNode.textContent;
+ }
+ // Constructed stylesheet.
+ // TODO(bug 1769933, bug 1809108): Maybe preserve authored text?
+ return "";
+ }
+
+ return fetchStyleSheetText(styleSheet);
+}
+
+exports.getStyleSheetText = getStyleSheetText;
+
+/**
+ * Retrieve the content of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String}
+ */
+async function fetchStyleSheetText(styleSheet) {
+ const href = styleSheet.href;
+
+ const options = {
+ loadFromCache: true,
+ policy: Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET,
+ charset: getCSSCharset(styleSheet),
+ headers: {
+ // https://searchfox.org/mozilla-central/rev/68b1b0041a78abd06f19202558ccc4922e5ba759/netwerk/protocol/http/nsHttpHandler.cpp#124
+ accept: "text/css,*/*;q=0.1",
+ },
+ };
+
+ // Bug 1282660 - We use the system principal to load the default internal
+ // stylesheets instead of the content principal since such stylesheets
+ // require system principal to load. At meanwhile, we strip the loadGroup
+ // for preventing the assertion of the userContextId mismatching.
+
+ // chrome|file|resource|moz-extension protocols rely on the system principal.
+ const excludedProtocolsRe = /^(chrome|file|resource|moz-extension):\/\//;
+ if (!excludedProtocolsRe.test(href)) {
+ // Stylesheets using other protocols should use the content principal.
+ const ownerNode = getStyleSheetOwnerNode(styleSheet);
+ if (ownerNode) {
+ // eslint-disable-next-line mozilla/use-ownerGlobal
+ options.window = ownerNode.ownerDocument.defaultView;
+ options.principal = ownerNode.ownerDocument.nodePrincipal;
+ }
+ }
+
+ let result;
+
+ try {
+ result = await fetch(href, options);
+ if (result.contentType !== "text/css") {
+ console.warn(
+ `stylesheets: fetch from cache returned non-css content-type ` +
+ `${result.contentType} for ${href}, trying without cache.`
+ );
+ options.loadFromCache = false;
+ result = await fetch(href, options);
+ }
+ } catch (e) {
+ // The list of excluded protocols can be missing some protocols, try to use the
+ // system principal if the first fetch failed.
+ console.error(
+ `stylesheets: fetch failed for ${href},` +
+ ` using system principal instead.`
+ );
+ options.window = undefined;
+ options.principal = undefined;
+ result = await fetch(href, options);
+ }
+
+ return result.content;
+}
+
+/**
+ * Get charset of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String}
+ */
+function getCSSCharset(styleSheet) {
+ if (styleSheet) {
+ // charset attribute of <link> or <style> element, if it exists
+ if (styleSheet.ownerNode?.getAttribute) {
+ const linkCharset = styleSheet.ownerNode.getAttribute("charset");
+ if (linkCharset != null) {
+ return linkCharset;
+ }
+ }
+
+ // charset of referring document.
+ if (styleSheet.ownerNode?.ownerDocument.characterSet) {
+ return styleSheet.ownerNode.ownerDocument.characterSet;
+ }
+ }
+
+ return "UTF-8";
+}
diff --git a/devtools/server/actors/utils/stylesheets-manager.js b/devtools/server/actors/utils/stylesheets-manager.js
new file mode 100644
index 0000000000..838e5be602
--- /dev/null
+++ b/devtools/server/actors/utils/stylesheets-manager.js
@@ -0,0 +1,1031 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const {
+ getSourcemapBaseURL,
+} = require("resource://devtools/server/actors/utils/source-map-utils.js");
+
+loader.lazyRequireGetter(
+ this,
+ ["addPseudoClassLock", "removePseudoClassLock"],
+ "resource://devtools/server/actors/highlighters/utils/markup.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "loadSheet",
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["getStyleSheetOwnerNode", "getStyleSheetText"],
+ "resource://devtools/server/actors/utils/stylesheet-utils.js",
+ true
+);
+
+const TRANSITION_PSEUDO_CLASS = ":-moz-styleeditor-transitioning";
+const TRANSITION_DURATION_MS = 500;
+const TRANSITION_BUFFER_MS = 1000;
+const TRANSITION_RULE_SELECTOR = `:root${TRANSITION_PSEUDO_CLASS}, :root${TRANSITION_PSEUDO_CLASS} *:not(:-moz-native-anonymous)`;
+const TRANSITION_SHEET =
+ "data:text/css;charset=utf-8," +
+ encodeURIComponent(`
+ ${TRANSITION_RULE_SELECTOR} {
+ transition-duration: ${TRANSITION_DURATION_MS}ms !important;
+ transition-delay: 0ms !important;
+ transition-timing-function: ease-out !important;
+ transition-property: all !important;
+ }
+`);
+
+// The possible kinds of style-applied events.
+// UPDATE_PRESERVING_RULES means that the update is guaranteed to
+// preserve the number and order of rules on the style sheet.
+// UPDATE_GENERAL covers any other kind of change to the style sheet.
+const UPDATE_PRESERVING_RULES = 0;
+const UPDATE_GENERAL = 1;
+
+// If the user edits a stylesheet, we stash a copy of the edited text
+// here, keyed by the stylesheet. This way, if the tools are closed
+// and then reopened, the edited text will be available. A weak map
+// is used so that navigation by the user will eventually cause the
+// edited text to be collected.
+const modifiedStyleSheets = new WeakMap();
+
+/**
+ * Manage stylesheets related to a given Target Actor.
+ * @emits stylesheet-updated: emitted when there was changes in a stylesheet
+ * First arg is an object with the following properties:
+ * - resourceId {String}: The id that was assigned to the stylesheet
+ * - updateKind {String}: Which kind of update it is ("style-applied",
+ * "at-rules-changed", "matches-change", "property-change")
+ * - updates {Object}: The update data
+ */
+class StyleSheetsManager extends EventEmitter {
+ #abortController;
+ // Map<resourceId, AbortController>
+ #mqlChangeAbortControllerMap = new Map();
+ #styleSheetCount = 0;
+ #styleSheetMap = new Map();
+ #styleSheetCreationData;
+ #targetActor;
+ #transitionSheetLoaded;
+ #transitionTimeout;
+ #watchListeners = {
+ onAvailable: [],
+ onUpdated: [],
+ onDestroyed: [],
+ };
+
+ /**
+ * @param TargetActor targetActor
+ * The target actor from which we should observe stylesheet changes.
+ */
+ constructor(targetActor) {
+ super();
+
+ this.#targetActor = targetActor;
+ }
+
+ #setEventListenersIfNeeded() {
+ if (this.#abortController) {
+ return;
+ }
+
+ this.#abortController = new AbortController();
+ const { signal } = this.#abortController;
+
+ // Listen for new stylesheet being added via StyleSheetApplicableStateChanged
+ this.#targetActor.chromeEventHandler.addEventListener(
+ "StyleSheetApplicableStateChanged",
+ this.#onApplicableStateChanged,
+ { capture: true, signal }
+ );
+ this.#targetActor.chromeEventHandler.addEventListener(
+ "StyleSheetRemoved",
+ this.#onStylesheetRemoved,
+ { capture: true, signal }
+ );
+
+ this.#watchStyleSheetChangeEvents();
+ this.#targetActor.on("window-ready", this.#onTargetActorWindowReady, {
+ signal,
+ });
+ }
+
+ /**
+ * Calling this function will make the StyleSheetsManager start the event listeners needed
+ * to watch for stylesheet additions and modifications.
+ * This resolves once it notified about existing stylesheets.
+ * @param {Object} options
+ * @param {Function} onAvailable: Function that will be called when a stylesheet is
+ * registered, but also with already registered stylesheets
+ * if ignoreExisting is not set to true.
+ * This is called with a single object parameter with the following properties:
+ * - {String} resourceId: The id that was assigned to the stylesheet
+ * - {StyleSheet} styleSheet: The actual stylesheet object
+ * - {Object} creationData: An object with:
+ * - {Boolean} isCreatedByDevTools: Was the stylesheet created
+ * by DevTools (e.g. by the user clicking the new stylesheet
+ * button in the styleeditor)
+ * - {String} fileName
+ * @param {Function} onUpdated: Function that will be called when a stylesheet is updated
+ * This is called with a single object parameter with the following properties:
+ * - {String} resourceId: The id that was assigned to the stylesheet
+ * - {String} updateKind: Which kind of update it is ("style-applied",
+ * "at-rules-changed", "matches-change", "property-change")
+ * - {Object} updates : The update data
+ * @param {Function} onDestroyed: Function that will be called when a stylesheet is removed
+ * This is called with a single object parameter with the following properties:
+ * - {String} resourceId: The id that was assigned to the stylesheet
+ * @param {Boolean} ignoreExisting: Pass to true to avoid onAvailable to be called with
+ * already registered stylesheets.
+ */
+ async watch({ onAvailable, onUpdated, onDestroyed, ignoreExisting = false }) {
+ if (!onAvailable && !onUpdated && !onDestroyed) {
+ throw new Error("Expect onAvailable, onUpdated or onDestroyed");
+ }
+
+ if (onAvailable) {
+ if (typeof onAvailable !== "function") {
+ throw new Error("onAvailable should be a function");
+ }
+
+ // Don't register the listener yet if we're ignoring existing stylesheets, we'll do
+ // that at the end of the function, after we processed existing stylesheets.
+ }
+
+ if (onUpdated) {
+ if (typeof onUpdated !== "function") {
+ throw new Error("onUpdated should be a function");
+ }
+ this.#watchListeners.onUpdated.push(onUpdated);
+ }
+
+ if (onDestroyed) {
+ if (typeof onDestroyed !== "function") {
+ throw new Error("onDestroyed should be a function");
+ }
+ this.#watchListeners.onDestroyed.push(onDestroyed);
+ }
+
+ // Process existing stylesheets
+ const promises = [];
+ for (const window of this.#targetActor.windows) {
+ promises.push(this.#getStyleSheetsForWindow(window));
+ }
+
+ this.#setEventListenersIfNeeded();
+
+ // Finally, notify about existing stylesheets
+ const styleSheets = await Promise.all(promises);
+ const styleSheetsData = styleSheets.flat().map(styleSheet => ({
+ styleSheet,
+ resourceId: this.#registerStyleSheet(styleSheet),
+ }));
+
+ let registeredStyleSheetsPromises;
+ if (onAvailable && ignoreExisting !== true) {
+ registeredStyleSheetsPromises = styleSheetsData.map(
+ ({ resourceId, styleSheet }) => onAvailable({ resourceId, styleSheet })
+ );
+ }
+
+ // Only register the listener after we went over the list of existing stylesheets
+ // so the listener is not triggered by possible calls to #registerStyleSheet earlier.
+ if (onAvailable) {
+ this.#watchListeners.onAvailable.push(onAvailable);
+ }
+
+ if (registeredStyleSheetsPromises) {
+ await Promise.all(registeredStyleSheetsPromises);
+ }
+ }
+
+ /**
+ * Remove the passed listeners
+ *
+ * @param {Object} options: See this.watch
+ */
+ unwatch({ onAvailable, onUpdated, onDestroyed }) {
+ if (!this.#watchListeners) {
+ return;
+ }
+
+ if (onAvailable) {
+ const index = this.#watchListeners.onAvailable.indexOf(onAvailable);
+ if (index !== -1) {
+ this.#watchListeners.onAvailable.splice(index, 1);
+ }
+ }
+
+ if (onUpdated) {
+ const index = this.#watchListeners.onUpdated.indexOf(onUpdated);
+ if (index !== -1) {
+ this.#watchListeners.onUpdated.splice(index, 1);
+ }
+ }
+
+ if (onDestroyed) {
+ const index = this.#watchListeners.onDestroyed.indexOf(onDestroyed);
+ if (index !== -1) {
+ this.#watchListeners.onDestroyed.splice(index, 1);
+ }
+ }
+ }
+
+ #watchStyleSheetChangeEvents() {
+ for (const window of this.#targetActor.windows) {
+ this.#watchStyleSheetChangeEventsForWindow(window);
+ }
+ }
+
+ #onTargetActorWindowReady = ({ window }) => {
+ this.#watchStyleSheetChangeEventsForWindow(window);
+ };
+
+ #watchStyleSheetChangeEventsForWindow(window) {
+ // We have to set this flag in order to get the
+ // StyleSheetApplicableStateChanged and StyleSheetRemoved events. See Document.webidl.
+ window.document.styleSheetChangeEventsEnabled = true;
+ }
+
+ #unwatchStyleSheetChangeEvents() {
+ for (const window of this.#targetActor.windows) {
+ window.document.styleSheetChangeEventsEnabled = false;
+ }
+ }
+
+ /**
+ * Create a new style sheet in the document with the given text.
+ *
+ * @param {Document} document
+ * Document that the new style sheet belong to.
+ * @param {string} text
+ * Content of style sheet.
+ * @param {string} fileName
+ * If the stylesheet adding is from file, `fileName` indicates the path.
+ */
+ async addStyleSheet(document, text, fileName) {
+ const parent = document.documentElement;
+ const style = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "style"
+ );
+ style.setAttribute("type", "text/css");
+ style.setDevtoolsAsTriggeringPrincipal();
+
+ if (text) {
+ style.appendChild(document.createTextNode(text));
+ }
+
+ // This triggers StyleSheetApplicableStateChanged event.
+ parent.appendChild(style);
+
+ // This promise will be resolved when the resource for this stylesheet is available.
+ let resolve = null;
+ const promise = new Promise(r => {
+ resolve = r;
+ });
+
+ if (!this.#styleSheetCreationData) {
+ this.#styleSheetCreationData = new WeakMap();
+ }
+ this.#styleSheetCreationData.set(style.sheet, {
+ isCreatedByDevTools: true,
+ fileName,
+ resolve,
+ });
+
+ await promise;
+
+ return style.sheet;
+ }
+
+ /**
+ * Return resourceId of the given style sheet or create one if the stylesheet wasn't
+ * registered yet.
+ *
+ * @params {StyleSheet} styleSheet
+ * @returns {String} resourceId
+ */
+ getStyleSheetResourceId(styleSheet) {
+ const existingResourceId = this.#findStyleSheetResourceId(styleSheet);
+ if (existingResourceId) {
+ return existingResourceId;
+ }
+
+ // If we couldn't find an associated resourceId, that means the stylesheet isn't
+ // registered yet. Calling #registerStyleSheet will register it and return the
+ // associated resourceId it computed for it.
+ return this.#registerStyleSheet(styleSheet);
+ }
+
+ /**
+ * Return the associated resourceId of the given registered style sheet, or null if the
+ * stylesheet wasn't registered yet.
+ *
+ * @params {StyleSheet} styleSheet
+ * @returns {String} resourceId
+ */
+ #findStyleSheetResourceId(styleSheet) {
+ for (const [
+ resourceId,
+ existingStyleSheet,
+ ] of this.#styleSheetMap.entries()) {
+ if (styleSheet === existingStyleSheet) {
+ return resourceId;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Return owner node of the style sheet of the given resource id.
+ *
+ * @params {String} resourceId
+ * The id associated with the stylesheet
+ * @returns {Element|null}
+ */
+ getOwnerNode(resourceId) {
+ const styleSheet = this.#styleSheetMap.get(resourceId);
+ return styleSheet.ownerNode;
+ }
+
+ /**
+ * Return the index of given stylesheet of the given resource id.
+ *
+ * @params {String} resourceId
+ * The id associated with the stylesheet
+ * @returns {Number}
+ */
+ getStyleSheetIndex(resourceId) {
+ const styleSheet = this.#styleSheetMap.get(resourceId);
+
+ const styleSheets = InspectorUtils.getAllStyleSheets(
+ this.#targetActor.window.document,
+ true
+ );
+ let i = 0;
+ for (const sheet of styleSheets) {
+ if (!this.#shouldListSheet(sheet)) {
+ continue;
+ }
+ if (sheet == styleSheet) {
+ return i;
+ }
+ i++;
+ }
+ return -1;
+ }
+
+ /**
+ * Get the text of a stylesheet given its resourceId.
+ *
+ * @params {String} resourceId
+ * The id associated with the stylesheet
+ * @returns {String}
+ */
+ async getText(resourceId) {
+ const styleSheet = this.#styleSheetMap.get(resourceId);
+
+ const modifiedText = modifiedStyleSheets.get(styleSheet);
+
+ // modifiedText is the content of the stylesheet updated by update function.
+ // In case not updating, this is undefined.
+ if (modifiedText !== undefined) {
+ return modifiedText;
+ }
+
+ return getStyleSheetText(styleSheet);
+ }
+
+ /**
+ * Toggle the disabled property of the stylesheet
+ *
+ * @params {String} resourceId
+ * The id associated with the stylesheet
+ * @return {Boolean} the disabled state after toggling.
+ */
+ toggleDisabled(resourceId) {
+ const styleSheet = this.#styleSheetMap.get(resourceId);
+ styleSheet.disabled = !styleSheet.disabled;
+
+ this.#notifyPropertyChanged(resourceId, "disabled", styleSheet.disabled);
+
+ return styleSheet.disabled;
+ }
+
+ /**
+ * Update the style sheet in place with new text.
+ *
+ * @param {String} resourceId
+ * @param {String} text
+ * New text.
+ * @param {Object} options
+ * @param {Boolean} options.transition
+ * Whether to do CSS transition for change. Defaults to false.
+ * @param {Number} options.kind
+ * Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL. Defaults to UPDATE_GENERAL.
+ * @param {String} options.cause
+ * Indicates the cause of this update (e.g. "styleeditor") if this was called
+ * from the stylesheet to be edited by the user from the StyleEditor.
+ */
+ async setStyleSheetText(
+ resourceId,
+ text,
+ { transition = false, kind = UPDATE_GENERAL, cause = "" } = {}
+ ) {
+ const styleSheet = this.#styleSheetMap.get(resourceId);
+ InspectorUtils.parseStyleSheet(styleSheet, text);
+ modifiedStyleSheets.set(styleSheet, text);
+
+ const { atRules, ruleCount } =
+ this.getStyleSheetRuleCountAndAtRules(styleSheet);
+
+ if (kind !== UPDATE_PRESERVING_RULES) {
+ this.#notifyPropertyChanged(resourceId, "ruleCount", ruleCount);
+ }
+
+ if (transition) {
+ this.#startTransition(resourceId, kind, cause);
+ } else {
+ this.#onStyleSheetUpdated({
+ resourceId,
+ updateKind: "style-applied",
+ updates: {
+ event: { kind, cause },
+ },
+ });
+ }
+
+ this.#onStyleSheetUpdated({
+ resourceId,
+ updateKind: "at-rules-changed",
+ updates: {
+ resourceUpdates: { atRules },
+ },
+ });
+ }
+
+ /**
+ * Applies a transition to the stylesheet document so any change made by the user in the
+ * client will be animated so it's more visible.
+ *
+ * @param {String} resourceId
+ * The id associated with the stylesheet
+ * @param {Number} kind
+ * Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL
+ * @param {String} cause
+ * Indicates the cause of this update (e.g. "styleeditor") if this was called
+ * from the stylesheet to be edited by the user from the StyleEditor.
+ */
+ #startTransition(resourceId, kind, cause) {
+ const styleSheet = this.#styleSheetMap.get(resourceId);
+ const document = styleSheet.associatedDocument;
+ const window = document.ownerGlobal;
+
+ if (!this.#transitionSheetLoaded) {
+ this.#transitionSheetLoaded = true;
+ // We don't remove this sheet. It uses an internal selector that
+ // we only apply via locks, so there's no need to load and unload
+ // it all the time.
+ loadSheet(window, TRANSITION_SHEET);
+ }
+
+ addPseudoClassLock(document.documentElement, TRANSITION_PSEUDO_CLASS);
+
+ // Set up clean up and commit after transition duration (+buffer)
+ // @see #onTransitionEnd
+ window.clearTimeout(this.#transitionTimeout);
+ this.#transitionTimeout = window.setTimeout(
+ this.#onTransitionEnd.bind(this, resourceId, kind, cause),
+ TRANSITION_DURATION_MS + TRANSITION_BUFFER_MS
+ );
+ }
+
+ /**
+ * @param {String} resourceId
+ * The id associated with the stylesheet
+ * @param {Number} kind
+ * Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL
+ * @param {String} cause
+ * Indicates the cause of this update (e.g. "styleeditor") if this was called
+ * from the stylesheet to be edited by the user from the StyleEditor.
+ */
+ #onTransitionEnd(resourceId, kind, cause) {
+ const styleSheet = this.#styleSheetMap.get(resourceId);
+ const document = styleSheet.associatedDocument;
+
+ this.#transitionTimeout = null;
+ removePseudoClassLock(document.documentElement, TRANSITION_PSEUDO_CLASS);
+
+ this.#onStyleSheetUpdated({
+ resourceId,
+ updateKind: "style-applied",
+ updates: {
+ event: { kind, cause },
+ },
+ });
+ }
+
+ /**
+ * Retrieve the CSSRuleList of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {CSSRuleList}
+ */
+ #getCSSRules(styleSheet) {
+ try {
+ return styleSheet.cssRules;
+ } catch (e) {
+ // sheet isn't loaded yet
+ }
+
+ if (!styleSheet.ownerNode) {
+ return Promise.resolve([]);
+ }
+
+ return new Promise(resolve => {
+ styleSheet.ownerNode.addEventListener(
+ "load",
+ () => resolve(styleSheet.cssRules),
+ { once: true }
+ );
+ });
+ }
+
+ /**
+ * Get the stylesheets imported by a given stylesheet (via @import)
+ *
+ * @param {Document} document
+ * @param {StyleSheet} styleSheet
+ * @returns Array<StyleSheet>
+ */
+ async #getImportedStyleSheets(document, styleSheet) {
+ const importedStyleSheets = [];
+
+ for (const rule of await this.#getCSSRules(styleSheet)) {
+ const ruleClassName = ChromeUtils.getClassName(rule);
+ if (ruleClassName == "CSSImportRule") {
+ // With the Gecko style system, the associated styleSheet may be null
+ // if it has already been seen because an import cycle for the same
+ // URL. With Stylo, the styleSheet will exist (which is correct per
+ // the latest CSSOM spec), so we also need to check ancestors for the
+ // same URL to avoid cycles.
+ if (
+ !rule.styleSheet ||
+ this.#haveAncestorWithSameURL(rule.styleSheet) ||
+ !this.#shouldListSheet(rule.styleSheet)
+ ) {
+ continue;
+ }
+
+ importedStyleSheets.push(rule.styleSheet);
+
+ // recurse imports in this stylesheet as well
+ const children = await this.#getImportedStyleSheets(
+ document,
+ rule.styleSheet
+ );
+ importedStyleSheets.push(...children);
+ } else if (ruleClassName != "CSSCharsetRule") {
+ // @import rules must precede all others except @charset
+ break;
+ }
+ }
+
+ return importedStyleSheets;
+ }
+
+ /**
+ * Retrieve the total number of rules (including nested ones) and
+ * all the at-rules of a given stylesheet.
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {Object} An object of the following shape:
+ * - {Integer} ruleCount: The total number of rules in the stylesheet
+ * - {Array<Object>} atRules: An array of object of the following shape:
+ * - type {String}
+ * - conditionText {String}
+ * - matches {Boolean}: true if the media rule matches the current state of the document
+ * - layerName {String}
+ * - line {Number}
+ * - column {Number}
+ */
+ getStyleSheetRuleCountAndAtRules(styleSheet) {
+ const resourceId = this.#findStyleSheetResourceId(styleSheet);
+ if (!resourceId) {
+ return [];
+ }
+
+ if (this.#mqlChangeAbortControllerMap.has(resourceId)) {
+ this.#mqlChangeAbortControllerMap.get(resourceId).abort();
+ this.#mqlChangeAbortControllerMap.delete(resourceId);
+ }
+
+ // Accessing the stylesheet associated window might be slow due to cross compartment
+ // wrappers, so only retrieve it if it's needed.
+ let win;
+ const getStyleSheetAssociatedWindow = () => {
+ if (!win) {
+ win = styleSheet.associatedDocument?.ownerGlobal;
+ }
+ return win;
+ };
+
+ const styleSheetRules =
+ InspectorUtils.getAllStyleSheetCSSStyleRules(styleSheet);
+ const ruleCount = styleSheetRules.length;
+ // We need to go through nested rules to extract all the rules we're interested in
+ const atRules = [];
+ for (const rule of styleSheetRules) {
+ const className = ChromeUtils.getClassName(rule);
+ if (className === "CSSMediaRule") {
+ let matches = false;
+
+ try {
+ const associatedWin = getStyleSheetAssociatedWindow();
+ const mql = associatedWin.matchMedia(rule.media.mediaText);
+ matches = mql.matches;
+
+ let ac = this.#mqlChangeAbortControllerMap.get(resourceId);
+ if (!ac) {
+ ac = new associatedWin.AbortController();
+ this.#mqlChangeAbortControllerMap.set(resourceId, ac);
+ }
+
+ const index = atRules.length;
+ mql.addEventListener(
+ "change",
+ () => this.#onMatchesChange(resourceId, index, mql),
+ {
+ signal: ac.signal,
+ }
+ );
+ } catch (e) {
+ // Ignored
+ }
+
+ atRules.push({
+ type: "media",
+ conditionText: rule.conditionText,
+ matches,
+ line: InspectorUtils.getRelativeRuleLine(rule),
+ column: InspectorUtils.getRuleColumn(rule),
+ });
+ } else if (className === "CSSContainerRule") {
+ atRules.push({
+ type: "container",
+ conditionText: rule.conditionText,
+ line: InspectorUtils.getRelativeRuleLine(rule),
+ column: InspectorUtils.getRuleColumn(rule),
+ });
+ } else if (className === "CSSSupportsRule") {
+ atRules.push({
+ type: "support",
+ conditionText: rule.conditionText,
+ line: InspectorUtils.getRelativeRuleLine(rule),
+ column: InspectorUtils.getRuleColumn(rule),
+ });
+ } else if (className === "CSSLayerBlockRule") {
+ atRules.push({
+ type: "layer",
+ layerName: rule.name,
+ line: InspectorUtils.getRelativeRuleLine(rule),
+ column: InspectorUtils.getRuleColumn(rule),
+ });
+ }
+ }
+ return { ruleCount, atRules };
+ }
+
+ /**
+ * Called when the status of a media query support changes (i.e. it now matches, or it
+ * was matching but isn't anymore)
+ *
+ * @param {String} resourceId
+ * The id associated with the stylesheet
+ * @param {Number} index
+ * The index of the media rule relatively to all the other at-rules of the stylesheet
+ * @param {MediaQueryList} mql
+ * The result of matchMedia for the given media rule
+ */
+ #onMatchesChange(resourceId, index, mql) {
+ this.#onStyleSheetUpdated({
+ resourceId,
+ updateKind: "matches-change",
+ updates: {
+ nestedResourceUpdates: [
+ {
+ path: ["atRules", index, "matches"],
+ value: mql.matches,
+ },
+ ],
+ },
+ });
+ }
+
+ /**
+ * Get the node href of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String}
+ */
+ getNodeHref(styleSheet) {
+ const { ownerNode } = styleSheet;
+ if (!ownerNode) {
+ return null;
+ }
+
+ if (ownerNode.nodeType == ownerNode.DOCUMENT_NODE) {
+ return ownerNode.location.href;
+ }
+
+ if (ownerNode.ownerDocument?.location) {
+ return ownerNode.ownerDocument.location.href;
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the sourcemap base url of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String}
+ */
+ getSourcemapBaseURL(styleSheet) {
+ // When the style is injected via nsIDOMWindowUtils.loadSheet, even
+ // the parent style sheet has no owner, so default back to target actor
+ // document
+ const ownerNode = getStyleSheetOwnerNode(styleSheet);
+ const ownerDocument = ownerNode
+ ? ownerNode.ownerDocument
+ : this.#targetActor.window;
+
+ return getSourcemapBaseURL(
+ // Technically resolveSourceURL should be used here alongside
+ // "this.rawSheet.sourceURL", but the style inspector does not support
+ // /*# sourceURL=*/ in CSS, so we're omitting it here (bug 880831).
+ styleSheet.href || this.getNodeHref(styleSheet),
+ ownerDocument
+ );
+ }
+
+ /**
+ * Get all the stylesheets for a given window
+ *
+ * @param {Window} window
+ * @returns {Array<StyleSheet>}
+ */
+ async #getStyleSheetsForWindow(window) {
+ const { document } = window;
+ const documentOnly = !document.nodePrincipal.isSystemPrincipal;
+
+ const styleSheets = [];
+
+ for (const styleSheet of InspectorUtils.getAllStyleSheets(
+ document,
+ documentOnly
+ )) {
+ if (!this.#shouldListSheet(styleSheet)) {
+ continue;
+ }
+
+ styleSheets.push(styleSheet);
+
+ // Get all sheets, including imported ones
+ const importedStyleSheets = await this.#getImportedStyleSheets(
+ document,
+ styleSheet
+ );
+ styleSheets.push(...importedStyleSheets);
+ }
+
+ return styleSheets;
+ }
+
+ /**
+ * Returns true if a given stylesheet has an ancestor with the same url it has
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {Boolean}
+ */
+ #haveAncestorWithSameURL(styleSheet) {
+ const href = styleSheet.href;
+ while (styleSheet.parentStyleSheet) {
+ if (styleSheet.parentStyleSheet.href == href) {
+ return true;
+ }
+ styleSheet = styleSheet.parentStyleSheet;
+ }
+ return false;
+ }
+
+ /**
+ * Helper function called when a property changed in a given stylesheet
+ *
+ * @param {String} resourceId
+ * The id of the stylesheet the change occured in
+ * @param {String} property
+ * The property that was changed
+ * @param {String} value
+ * The value of the property
+ */
+ #notifyPropertyChanged(resourceId, property, value) {
+ this.#onStyleSheetUpdated({
+ resourceId,
+ updateKind: "property-change",
+ updates: { resourceUpdates: { [property]: value } },
+ });
+ }
+
+ /**
+ * Event handler that is called when the state of applicable of style sheet is changed.
+ *
+ * For now, StyleSheetApplicableStateChanged event will be called at following timings.
+ * - Append <link> of stylesheet to document
+ * - Append <style> to document
+ * - Change disable attribute of stylesheet object
+ * - Change disable attribute of <link> to false
+ * - Stylesheet is constructed.
+ * When appending <link>, <style> or changing `disabled` attribute to false,
+ * `applicable` is passed as true. The other hand, when changing `disabled`
+ * to true, this will be false.
+ *
+ * NOTE: StyleSheetApplicableStateChanged is _not_ called when removing the <link>/<style>,
+ * but a StyleSheetRemovedEvent is emitted in such case (see #onStyleSheetRemoved)
+ *
+ * @param {StyleSheetApplicableStateChangedEvent}
+ * The triggering event.
+ */
+ #onApplicableStateChanged = ({ applicable, stylesheet: styleSheet }) => {
+ if (
+ // Have interest in applicable stylesheet only.
+ applicable &&
+ styleSheet.associatedDocument &&
+ (!this.#targetActor.ignoreSubFrames ||
+ styleSheet.associatedDocument.ownerGlobal ===
+ this.#targetActor.window) &&
+ this.#shouldListSheet(styleSheet) &&
+ !this.#haveAncestorWithSameURL(styleSheet)
+ ) {
+ this.#registerStyleSheet(styleSheet);
+ }
+ };
+
+ /**
+ * Event handler that is called when a style sheet is removed.
+ *
+ * @param {StyleSheetRemovedEvent}
+ * The triggering event.
+ */
+ #onStylesheetRemoved = event => {
+ this.#unregisterStyleSheet(event.stylesheet);
+ };
+
+ /**
+ * If the stylesheet isn't registered yet, this function will generate an associated
+ * resourceId and call registered `onAvailable` listeners.
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String} the associated resourceId
+ */
+ #registerStyleSheet(styleSheet) {
+ const existingResourceId = this.#findStyleSheetResourceId(styleSheet);
+ // If the stylesheet is already registered, there's no need to notify about it again.
+ if (existingResourceId) {
+ return existingResourceId;
+ }
+
+ // It's important to prefix the resourceId with the target actorID so we can't have
+ // duplicated resource ids when the client connects to multiple targets.
+ const resourceId = `${this.#targetActor.actorID}:stylesheet:${this
+ .#styleSheetCount++}`;
+ this.#styleSheetMap.set(resourceId, styleSheet);
+
+ const creationData = this.#styleSheetCreationData?.get(styleSheet);
+ this.#styleSheetCreationData?.delete(styleSheet);
+
+ const onAvailablePromises = [];
+ for (const onAvailable of this.#watchListeners.onAvailable) {
+ onAvailablePromises.push(
+ onAvailable({
+ resourceId,
+ styleSheet,
+ creationData,
+ })
+ );
+ }
+
+ // creationData exists if this stylesheet was created via `addStyleSheet`.
+ if (creationData) {
+ // We resolve the promise once the watcher sent the resources to the client,
+ // so `addStyleSheet` calls can be fullfilled.
+ Promise.all(onAvailablePromises).then(() => creationData?.resolve());
+ }
+ return resourceId;
+ }
+
+ /**
+ * If the stylesheet is registered, this function will call registered `onDestroyed`
+ * listeners with the stylesheet resourceId.
+ *
+ * @param {StyleSheet} styleSheet
+ */
+ #unregisterStyleSheet(styleSheet) {
+ const existingResourceId = this.#findStyleSheetResourceId(styleSheet);
+ if (!existingResourceId) {
+ return;
+ }
+
+ this.#styleSheetMap.delete(existingResourceId);
+ this.#styleSheetCreationData?.delete(styleSheet);
+ if (this.#mqlChangeAbortControllerMap.has(existingResourceId)) {
+ this.#mqlChangeAbortControllerMap.get(existingResourceId).abort();
+ this.#mqlChangeAbortControllerMap.delete(existingResourceId);
+ }
+
+ for (const onDestroyed of this.#watchListeners.onDestroyed) {
+ onDestroyed({
+ resourceId: existingResourceId,
+ });
+ }
+ }
+
+ #onStyleSheetUpdated(data) {
+ this.emit("stylesheet-updated", data);
+
+ for (const onUpdated of this.#watchListeners.onUpdated) {
+ onUpdated(data);
+ }
+ }
+
+ /**
+ * Returns true if the passed styleSheet should be handled.
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {Boolean}
+ */
+ #shouldListSheet(styleSheet) {
+ const href = styleSheet.href?.toLowerCase();
+ // FIXME(bug 1826538): Make accessiblecaret.css and similar UA-widget
+ // sheets system sheets, then remove this special-case.
+ if (
+ href === "resource://content-accessible/accessiblecaret.css" ||
+ (href === "resource://devtools-highlighter-styles/highlighters.css" &&
+ this.#targetActor.sessionContext.type !== "all")
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * The StyleSheetsManager instance is managed by the target, so this will be called when
+ * the target gets destroyed.
+ */
+ destroy() {
+ // Cleanup
+ if (this.#abortController) {
+ this.#abortController.abort();
+ }
+ if (this.#mqlChangeAbortControllerMap) {
+ for (const ac of this.#mqlChangeAbortControllerMap.values()) {
+ ac.abort();
+ }
+ }
+
+ try {
+ this.#unwatchStyleSheetChangeEvents();
+ } catch (e) {
+ console.error(
+ "Error when destroying StyleSheet manager for",
+ this.#targetActor,
+ ": ",
+ e
+ );
+ }
+
+ this.#styleSheetMap.clear();
+ this.#abortController = null;
+ this.#mqlChangeAbortControllerMap = null;
+ this.#styleSheetCreationData = null;
+ this.#styleSheetMap = null;
+ this.#targetActor = null;
+ this.#watchListeners = null;
+ }
+}
+
+module.exports = {
+ StyleSheetsManager,
+ UPDATE_GENERAL,
+ UPDATE_PRESERVING_RULES,
+};
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..19de2b92fb
--- /dev/null
+++ b/devtools/server/actors/utils/track-change-emitter.js
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+/**
+ * A helper class that is listened to by the ChangesActor, and can be
+ * used to send changes to the ChangesActor.
+ */
+class TrackChangeEmitter extends EventEmitter {
+ 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..a5ffb48fad
--- /dev/null
+++ b/devtools/server/actors/utils/walker-search.js
@@ -0,0 +1,320 @@
+/* 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",
+ "resource://devtools/server/actors/inspector/utils.js",
+ 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.
+ */
+
+class WalkerIndex {
+ /**
+ * 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
+ */
+ constructor(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);
+ }
+
+ /**
+ * Destroy this instance, releasing all data and references
+ */
+ destroy() {
+ this.walker.off("any-mutation", this.clearIndex);
+ }
+
+ clearIndex() {
+ 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(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,
+ node,
+ });
+ }
+
+ index() {
+ // 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 (
+ this.walker.targetActor.ignoreSubFrames &&
+ node.ownerDocument !== this.doc
+ ) {
+ continue;
+ }
+
+ 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;
+
+class WalkerSearch {
+ /**
+ * 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
+ */
+ constructor(walker) {
+ this.walker = walker;
+ this.index = new WalkerIndex(this.walker);
+ }
+
+ destroy() {
+ this.index.destroy();
+ this.walker = null;
+ }
+
+ _addResult(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(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(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(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(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,
+ 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..7e4e3ee54c
--- /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.shouldSkipAnyBreakpoint ||
+ !this.threadActor.hasMoved(frame, type) ||
+ this.threadActor.sourcesManager.isFrameBlackBoxed(frame)
+ ) {
+ return;
+ }
+
+ this.threadActor._pauseAndRespond(frame, {
+ 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;