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