From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- devtools/server/actors/utils/accessibility.js | 103 ++ devtools/server/actors/utils/actor-registry.js | 413 +++++++ .../server/actors/utils/breakpoint-actor-map.js | 74 ++ devtools/server/actors/utils/capture-screenshot.js | 200 ++++ devtools/server/actors/utils/css-grid-utils.js | 60 + devtools/server/actors/utils/custom-formatters.js | 499 +++++++++ devtools/server/actors/utils/dbg-source.js | 97 ++ devtools/server/actors/utils/event-breakpoints.js | 503 +++++++++ devtools/server/actors/utils/event-loop.js | 221 ++++ .../actors/utils/inactive-property-helper.js | 1163 ++++++++++++++++++++ devtools/server/actors/utils/logEvent.js | 104 ++ devtools/server/actors/utils/make-debugger.js | 119 ++ devtools/server/actors/utils/moz.build | 31 + devtools/server/actors/utils/shapes-utils.js | 149 +++ devtools/server/actors/utils/source-map-utils.js | 42 + devtools/server/actors/utils/source-url.js | 44 + devtools/server/actors/utils/sources-manager.js | 507 +++++++++ devtools/server/actors/utils/stack.js | 183 +++ devtools/server/actors/utils/style-utils.js | 211 ++++ devtools/server/actors/utils/stylesheet-utils.js | 143 +++ .../server/actors/utils/stylesheets-manager.js | 869 +++++++++++++++ .../server/actors/utils/track-change-emitter.js | 19 + devtools/server/actors/utils/walker-search.js | 320 ++++++ devtools/server/actors/utils/watchpoint-map.js | 163 +++ 24 files changed, 6237 insertions(+) create mode 100644 devtools/server/actors/utils/accessibility.js create mode 100644 devtools/server/actors/utils/actor-registry.js create mode 100644 devtools/server/actors/utils/breakpoint-actor-map.js create mode 100644 devtools/server/actors/utils/capture-screenshot.js create mode 100644 devtools/server/actors/utils/css-grid-utils.js create mode 100644 devtools/server/actors/utils/custom-formatters.js create mode 100644 devtools/server/actors/utils/dbg-source.js create mode 100644 devtools/server/actors/utils/event-breakpoints.js create mode 100644 devtools/server/actors/utils/event-loop.js create mode 100644 devtools/server/actors/utils/inactive-property-helper.js create mode 100644 devtools/server/actors/utils/logEvent.js create mode 100644 devtools/server/actors/utils/make-debugger.js create mode 100644 devtools/server/actors/utils/moz.build create mode 100644 devtools/server/actors/utils/shapes-utils.js create mode 100644 devtools/server/actors/utils/source-map-utils.js create mode 100644 devtools/server/actors/utils/source-url.js create mode 100644 devtools/server/actors/utils/sources-manager.js create mode 100644 devtools/server/actors/utils/stack.js create mode 100644 devtools/server/actors/utils/style-utils.js create mode 100644 devtools/server/actors/utils/stylesheet-utils.js create mode 100644 devtools/server/actors/utils/stylesheets-manager.js create mode 100644 devtools/server/actors/utils/track-change-emitter.js create mode 100644 devtools/server/actors/utils/walker-search.js create mode 100644 devtools/server/actors/utils/watchpoint-map.js (limited to 'devtools/server/actors/utils') 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..68d754cfb5 --- /dev/null +++ b/devtools/server/actors/utils/actor-registry.js @@ -0,0 +1,413 @@ +/* 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 }, + }); + }, + + /** + * 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..285f0fb7da --- /dev/null +++ b/devtools/server/actors/utils/breakpoint-actor-map.js @@ -0,0 +1,74 @@ +/* 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]; + } +} + +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}: 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..9de20dc143 --- /dev/null +++ b/devtools/server/actors/utils/event-breakpoints.js @@ -0,0 +1,503 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * + * @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: [ + generalEvent("control", "resize"), + generalEvent("control", "scroll"), + // The condition should be removed when "apz.scrollend-event.content.enabled" is removed + generalEvent("control", "scrollend", win => "onscrollend" in win), + generalEvent("control", "zoom"), + generalEvent("control", "focus"), + generalEvent("control", "focusin"), + generalEvent("control", "focusout"), + generalEvent("control", "blur"), + generalEvent("control", "select"), + generalEvent("control", "change"), + generalEvent("control", "submit"), + generalEvent("control", "reset"), + ], + }, + { + name: "DOM Mutation", + items: [ + // Deprecated DOM events. + nodeEvent("dom-mutation", "DOMActivate"), + nodeEvent("dom-mutation", "DOMFocusIn"), + nodeEvent("dom-mutation", "DOMFocusOut"), + + // Standard DOM mutation events. + nodeEvent("dom-mutation", "DOMAttrModified"), + nodeEvent("dom-mutation", "DOMCharacterDataModified"), + nodeEvent("dom-mutation", "DOMNodeInserted"), + nodeEvent("dom-mutation", "DOMNodeInsertedIntoDocument"), + nodeEvent("dom-mutation", "DOMNodeRemoved"), + nodeEvent("dom-mutation", "DOMNodeRemovedIntoDocument"), + nodeEvent("dom-mutation", "DOMSubtreeModified"), + + // DOM load events. + nodeEvent("dom-mutation", "DOMContentLoaded"), + ], + }, + { + name: "Device", + items: [ + globalEvent("device", "deviceorientation"), + globalEvent("device", "devicemotion"), + ], + }, + { + name: "Drag and Drop", + items: [ + generalEvent("drag-and-drop", "drag"), + generalEvent("drag-and-drop", "dragstart"), + generalEvent("drag-and-drop", "dragend"), + generalEvent("drag-and-drop", "dragenter"), + generalEvent("drag-and-drop", "dragover"), + generalEvent("drag-and-drop", "dragleave"), + generalEvent("drag-and-drop", "drop"), + ], + }, + { + name: "Keyboard", + items: [ + 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"), + // TODO: Disabled pending fixes for bug 1569775. + // globalEvent("load", "beforeunload"), + // globalEvent("load", "unload"), + globalEvent("load", "abort"), + globalEvent("load", "error"), + globalEvent("load", "hashchange"), + globalEvent("load", "popstate"), + ], + }, + { + name: "Media", + items: [ + mediaNodeEvent("media", "play"), + mediaNodeEvent("media", "pause"), + mediaNodeEvent("media", "playing"), + mediaNodeEvent("media", "canplay"), + mediaNodeEvent("media", "canplaythrough"), + mediaNodeEvent("media", "seeking"), + mediaNodeEvent("media", "seeked"), + mediaNodeEvent("media", "timeupdate"), + mediaNodeEvent("media", "ended"), + mediaNodeEvent("media", "ratechange"), + mediaNodeEvent("media", "durationchange"), + mediaNodeEvent("media", "volumechange"), + mediaNodeEvent("media", "loadstart"), + mediaNodeEvent("media", "progress"), + mediaNodeEvent("media", "suspend"), + mediaNodeEvent("media", "abort"), + mediaNodeEvent("media", "error"), + mediaNodeEvent("media", "emptied"), + mediaNodeEvent("media", "stalled"), + mediaNodeEvent("media", "loadedmetadata"), + mediaNodeEvent("media", "loadeddata"), + mediaNodeEvent("media", "waiting"), + ], + }, + { + name: "Mouse", + items: [ + generalEvent("mouse", "auxclick"), + generalEvent("mouse", "click"), + generalEvent("mouse", "dblclick"), + generalEvent("mouse", "mousedown"), + generalEvent("mouse", "mouseup"), + generalEvent("mouse", "mouseover"), + generalEvent("mouse", "mousemove"), + generalEvent("mouse", "mouseout"), + generalEvent("mouse", "mouseenter"), + generalEvent("mouse", "mouseleave"), + generalEvent("mouse", "mousewheel"), + generalEvent("mouse", "wheel"), + generalEvent("mouse", "contextmenu"), + ], + }, + { + name: "Pointer", + items: [ + generalEvent("pointer", "pointerover"), + generalEvent("pointer", "pointerout"), + generalEvent("pointer", "pointerenter"), + generalEvent("pointer", "pointerleave"), + generalEvent("pointer", "pointerdown"), + generalEvent("pointer", "pointerup"), + generalEvent("pointer", "pointermove"), + generalEvent("pointer", "pointercancel"), + generalEvent("pointer", "gotpointercapture"), + generalEvent("pointer", "lostpointercapture"), + ], + }, + { + name: "Script", + items: [SCRIPT_FIRST_STATEMENT_BREAKPOINT], + }, + { + name: "Timer", + items: [ + timerEvent("timeout", "set", "setTimeout", "setTimeout"), + timerEvent("timeout", "clear", "clearTimeout", "clearTimeout"), + timerEvent("timeout", "fire", "setTimeout fired", "setTimeoutCallback"), + timerEvent("interval", "set", "setInterval", "setInterval"), + timerEvent("interval", "clear", "clearInterval", "clearInterval"), + timerEvent( + "interval", + "fire", + "setInterval fired", + "setIntervalCallback" + ), + ], + }, + { + name: "Touch", + items: [ + generalEvent("touch", "touchstart"), + generalEvent("touch", "touchmove"), + generalEvent("touch", "touchend"), + generalEvent("touch", "touchcancel"), + ], + }, + { + name: "WebSocket", + items: [ + webSocketEvent("websocket", "open"), + webSocketEvent("websocket", "message"), + webSocketEvent("websocket", "error"), + webSocketEvent("websocket", "close"), + ], + }, + { + name: "Worker", + items: [ + workerEvent("message"), + workerEvent("messageerror"), + + // Service Worker events. + globalEvent("serviceworker", "fetch"), + ], + }, + { + name: "XHR", + items: [ + xhrEvent("xhr", "readystatechange"), + xhrEvent("xhr", "load"), + xhrEvent("xhr", "loadstart"), + xhrEvent("xhr", "loadend"), + xhrEvent("xhr", "abort"), + xhrEvent("xhr", "error"), + xhrEvent("xhr", "progress"), + xhrEvent("xhr", "timeout"), + ], + }, +]; + +const FLAT_EVENTS = []; +for (const category of AVAILABLE_BREAKPOINTS) { + for (const event of category.items) { + FLAT_EVENTS.push(event); + } +} +const EVENTS_BY_ID = {}; +for (const event of FLAT_EVENTS) { + if (EVENTS_BY_ID[event.id]) { + throw new Error("Duplicate event ID detected: " + event.id); + } + EVENTS_BY_ID[event.id] = event; +} + +const SIMPLE_EVENTS = {}; +const DOM_EVENTS = {}; +for (const eventBP of FLAT_EVENTS) { + if (eventBP.type === "simple") { + const { notificationType } = eventBP; + if (SIMPLE_EVENTS[notificationType]) { + throw new Error("Duplicate simple event"); + } + SIMPLE_EVENTS[notificationType] = eventBP.id; + } else if (eventBP.type === "event") { + const { eventType, filter } = eventBP; + + let targetTypes; + if (filter === "global") { + targetTypes = ["global"]; + } else if (filter === "xhr") { + targetTypes = ["xhr"]; + } else if (filter === "websocket") { + targetTypes = ["websocket"]; + } else if (filter === "worker") { + targetTypes = ["worker"]; + } else if (filter === "general") { + targetTypes = ["global", "node"]; + } else if (filter === "node" || filter === "media") { + targetTypes = ["node"]; + } else { + throw new Error("Unexpected filter type"); + } + + for (const targetType of targetTypes) { + let byEventType = DOM_EVENTS[targetType]; + if (!byEventType) { + byEventType = {}; + DOM_EVENTS[targetType] = byEventType; + } + + if (byEventType[eventType]) { + throw new Error("Duplicate dom event: " + eventType); + } + byEventType[eventType] = eventBP.id; + } + } else if (eventBP.type === "script") { + // Nothing to do. + } else { + throw new Error("Unknown type: " + eventBP.type); + } +} + +exports.eventBreakpointForNotification = eventBreakpointForNotification; +function eventBreakpointForNotification(dbg, notification) { + const notificationType = notification.type; + + if (notification.type === "domEvent") { + const domEventNotification = DOM_EVENTS[notification.targetType]; + if (!domEventNotification) { + return null; + } + + // The 'event' value is a cross-compartment wrapper for the DOM Event object. + // While we could use that directly in the main thread as an Xray wrapper, + // when debugging workers we can't, because it is an opaque wrapper. + // To make things work, we have to always interact with the Event object via + // the Debugger.Object interface. + const evt = dbg + .makeGlobalObjectReference(notification.global) + .makeDebuggeeValue(notification.event); + + const eventType = evt.getProperty("type").return; + const id = domEventNotification[eventType]; + if (!id) { + return null; + } + const eventBreakpoint = EVENTS_BY_ID[id]; + + if (eventBreakpoint.filter === "media") { + const currentTarget = evt.getProperty("currentTarget").return; + if (!currentTarget) { + return null; + } + + const nodeType = currentTarget.getProperty("nodeType").return; + const namespaceURI = currentTarget.getProperty("namespaceURI").return; + if ( + nodeType !== 1 /* ELEMENT_NODE */ || + namespaceURI !== "http://www.w3.org/1999/xhtml" + ) { + return null; + } + + const nodeName = currentTarget + .getProperty("nodeName") + .return.toLowerCase(); + if (nodeName !== "audio" && nodeName !== "video") { + return null; + } + } + + return id; + } + + return SIMPLE_EVENTS[notificationType] || null; +} + +exports.makeEventBreakpointMessage = makeEventBreakpointMessage; +function makeEventBreakpointMessage(id) { + return EVENTS_BY_ID[id].message; +} + +exports.firstStatementBreakpointId = firstStatementBreakpointId; +function firstStatementBreakpointId() { + return SCRIPT_FIRST_STATEMENT_BREAKPOINT.id; +} + +exports.eventsRequireNotifications = eventsRequireNotifications; +function eventsRequireNotifications(ids) { + for (const id of ids) { + const eventBreakpoint = EVENTS_BY_ID[id]; + + // Script events are implemented directly in the server and do not require + // notifications from Gecko, so there is no need to watch for them. + if (eventBreakpoint && eventBreakpoint.type !== "script") { + return true; + } + } + return false; +} + +exports.getAvailableEventBreakpoints = getAvailableEventBreakpoints; +/** + * Get all available event breakpoints + * + * @param {Window} window + * @returns {Array} 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/inactive-property-helper.js b/devtools/server/actors/utils/inactive-property-helper.js new file mode 100644 index 0000000000..253acc7b6e --- /dev/null +++ b/devtools/server/actors/utils/inactive-property-helper.js @@ -0,0 +1,1163 @@ +/* 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 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", +]); + +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 . + * + * There are so many properties in CSS that it's difficult to remember which + * ones do and don't apply in certain situations. Some are straight-forward + * like `flex-wrap` only applying to an element that has `display:flex`. + * Others are less trivial like setting something other than a color on a + * `:visited` pseudo-class. + * + * This file contains "rules" in the form of objects with the following + * properties: + * { + * invalidProperties: + * Array of CSS property names that are inactive if the rule matches. + * when: + * The rule itself, a JS function used to identify the conditions + * indicating whether a property is valid or not. + * fixId: + * A Fluent id containing a suggested solution to the problem that is + * causing a property to be inactive. + * msgId: + * A Fluent id containing an error message explaining why a property is + * inactive in this situation. + * } + * + * 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.
  • ). 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 + *
      element, or even the element) in order to concisely adjust the + * rendering of a whole list (or all the lists in a document). + */ + get VALIDATORS() { + return [ + // Flex container property used on non-flex container. + { + invalidProperties: ["flex-direction", "flex-flow", "flex-wrap"], + when: () => !this.flexContainer, + fixId: "inactive-css-not-flex-container-fix", + msgId: "inactive-css-not-flex-container", + }, + // 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", + }, + // Inline properties used on non-inline-level elements. + { + invalidProperties: ["vertical-align"], + when: () => { + const { selectorText } = this.cssRule; + + const isFirstLetter = + selectorText && selectorText.includes("::first-letter"); + const isFirstLine = + selectorText && selectorText.includes("::first-line"); + + return !this.isInlineLevel() && !isFirstLetter && !isFirstLine; + }, + fixId: "inactive-css-not-inline-or-tablecell-fix", + msgId: "inactive-css-not-inline-or-tablecell", + }, + // (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", + }, + // 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", + }, + ]; + } + + /** + * Get a list of unique CSS property names for which there are checks + * for used/unused state. + * + * @return {Set} + * List of CSS properties + */ + get invalidProperties() { + if (!this._invalidProperties) { + const allProps = this.VALIDATORS.map(v => v.invalidProperties).flat(); + this._invalidProperties = new Set(allProps); + } + + return this._invalidProperties; + } + + /** + * Is this CSS property having any effect on this element? + * + * @param {DOMNode} el + * The DOM element. + * @param {Style} elStyle + * The computed style for this DOMNode. + * @param {DOMRule} cssRule + * The CSS rule the property is defined in. + * @param {String} property + * The CSS property name. + * + * @return {Object} object + * @return {String} object.display + * The element computed display value. + * @return {String} object.fixId + * A Fluent id containing a suggested solution to the problem that is + * causing a property to be inactive. + * @return {String} object.msgId + * A Fluent id containing an error message explaining why a property + * is inactive in this situation. + * @return {String} object.property + * The inactive property name. + * @return {String} object.learnMoreURL + * An optional link if we need to open an other link than + * the default MDN property one. + * @return {Boolean} object.used + * true if the property is used. + */ + isPropertyUsed(el, elStyle, cssRule, property) { + // Assume the property is used when: + // - the Inactive CSS pref is not enabled + // - the property is not in the list of properties to check + if (!INACTIVE_CSS_ENABLED || !this.invalidProperties.has(property)) { + return { used: true }; + } + + let fixId = ""; + let msgId = ""; + let learnMoreURL = null; + let used = true; + + this.VALIDATORS.some(validator => { + // First check if this rule cares about this property. + let isRuleConcerned = false; + + if (validator.invalidProperties) { + isRuleConcerned = validator.invalidProperties.includes(property); + } + + if (!isRuleConcerned) { + return false; + } + + this.select(el, elStyle, cssRule, property); + + // And then run the validator, gathering the error message if the + // validator passes. + if (validator.when()) { + fixId = validator.fixId; + msgId = validator.msgId; + learnMoreURL = validator.learnMoreURL; + used = false; + + return true; + } + + return false; + }); + + this.unselect(); + + // Accessing elStyle might throws, we wrap it in a try/catch block to avoid test + // failures. + let display; + try { + display = elStyle ? elStyle.display : null; + } catch (e) {} + + return { + display, + fixId, + msgId, + property, + learnMoreURL, + used, + }; + } + + /** + * Focus on a node. + * + * @param {DOMNode} node + * Node to focus on. + */ + select(node, style, cssRule, property) { + this._node = node; + this._cssRule = cssRule; + this._property = property; + this._style = style; + } + + /** + * Clear references to avoid leaks. + */ + unselect() { + this._node = null; + this._cssRule = null; + this._property = null; + this._style = null; + } + + /** + * Provide a public reference to node. + */ + get node() { + return this._node; + } + + /** + * Cache and provide node's computed style. + */ + get style() { + return this._style; + } + + /** + * Provide a public reference to the css rule. + */ + get cssRule() { + return this._cssRule; + } + + /** + * Check if the current node's propName is set to one of the values passed in + * the values array. + * + * @param {String} propName + * Property name to check. + * @param {Array} values + * Values to compare against. + */ + checkComputedStyle(propName, values) { + if (!this.style) { + return false; + } + return values.some(value => this.style[propName] === value); + } + + /** + * Check if a rule's propName is set to one of the values passed in the values + * array. + * + * @param {String} propName + * Property name to check. + * @param {Array} values + * Values to compare against. + */ + checkResolvedStyle(propName, values) { + if (!(this.cssRule && this.cssRule.style)) { + return false; + } + const { style } = this.cssRule; + + return values.some(value => style[propName] === value); + } + + /** + * Check if the current node is an inline-level box. + */ + isInlineLevel() { + return this.checkComputedStyle("display", [ + "inline", + "inline-block", + "inline-table", + "inline-flex", + "inline-grid", + "table-cell", + "table-row", + "table-row-group", + "table-header-group", + "table-footer-group", + ]); + } + + /** + * Check if the current node is a flex container i.e. a node that has a style + * of `display:flex` or `display:inline-flex`. + */ + get flexContainer() { + return this.checkComputedStyle("display", ["flex", "inline-flex"]); + } + + /** + * Check if the current node is a flex item. + */ + get flexItem() { + return this.isFlexItem(this.node); + } + + /** + * Check if the current node is a grid container i.e. a node that has a style + * of `display:grid` or `display:inline-grid`. + */ + get gridContainer() { + return this.checkComputedStyle("display", ["grid", "inline-grid"]); + } + + /** + * Check if the current node is a grid item. + */ + get gridItem() { + return this.isGridItem(this.node); + } + + /** + * Check if the current node is a multi-column container, i.e. a node element whose + * `column-width` or `column-count` property is not `auto`. + */ + get multiColContainer() { + const autoColumnWidth = this.checkComputedStyle("column-width", ["auto"]); + const autoColumnCount = this.checkComputedStyle("column-count", ["auto"]); + + return !autoColumnWidth || !autoColumnCount; + } + + /** + * Check if the current node is a table row. + */ + get tableRow() { + return this.style && this.style.display === "table-row"; + } + + /** + * Check if the current node is a table column. + */ + get tableColumn() { + return this.style && this.style.display === "table-column"; + } + + /** + * Check if the current node is an internal table element. + */ + get internalTableElement() { + return this.checkComputedStyle("display", [ + "table-cell", + "table-row", + "table-row-group", + "table-header-group", + "table-footer-group", + "table-column", + "table-column-group", + ]); + } + + /** + * Check if the current node is a horizontal table track. That is: either a table row + * displayed in horizontal writing mode, or a table column displayed in vertical writing + * mode. + */ + get horizontalTableTrack() { + if (!this.tableRow && !this.tableColumn) { + return false; + } + + const 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 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. ,