diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/server/actors/inspector | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | devtools/server/actors/inspector/constants.js | 17 | ||||
-rw-r--r-- | devtools/server/actors/inspector/css-logic.js | 1646 | ||||
-rw-r--r-- | devtools/server/actors/inspector/custom-element-watcher.js | 144 | ||||
-rw-r--r-- | devtools/server/actors/inspector/document-walker.js | 196 | ||||
-rw-r--r-- | devtools/server/actors/inspector/event-collector.js | 1062 | ||||
-rw-r--r-- | devtools/server/actors/inspector/inspector.js | 355 | ||||
-rw-r--r-- | devtools/server/actors/inspector/moz.build | 21 | ||||
-rw-r--r-- | devtools/server/actors/inspector/node-picker.js | 435 | ||||
-rw-r--r-- | devtools/server/actors/inspector/node.js | 838 | ||||
-rw-r--r-- | devtools/server/actors/inspector/utils.js | 570 | ||||
-rw-r--r-- | devtools/server/actors/inspector/walker.js | 2753 |
11 files changed, 8037 insertions, 0 deletions
diff --git a/devtools/server/actors/inspector/constants.js b/devtools/server/actors/inspector/constants.js new file mode 100644 index 0000000000..c253c67b02 --- /dev/null +++ b/devtools/server/actors/inspector/constants.js @@ -0,0 +1,17 @@ +/* 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"; + +/** + * Any event listener flagged with this symbol will not be considered when + * the EventCollector class enumerates listeners for nodes. For example: + * + * const someListener = () => {}; + * someListener[EXCLUDED_LISTENER] = true; + * eventListenerService.addSystemEventListener(node, "event", someListener); + */ +const EXCLUDED_LISTENER = Symbol("event-collector-excluded-listener"); + +exports.EXCLUDED_LISTENER = EXCLUDED_LISTENER; diff --git a/devtools/server/actors/inspector/css-logic.js b/devtools/server/actors/inspector/css-logic.js new file mode 100644 index 0000000000..c081145428 --- /dev/null +++ b/devtools/server/actors/inspector/css-logic.js @@ -0,0 +1,1646 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * About the objects defined in this file: + * - CssLogic contains style information about a view context. It provides + * access to 2 sets of objects: Css[Sheet|Rule|Selector] provide access to + * information that does not change when the selected element changes while + * Css[Property|Selector]Info provide information that is dependent on the + * selected element. + * Its key methods are highlight(), getPropertyInfo() and forEachSheet(), etc + * + * - CssSheet provides a more useful API to a DOM CSSSheet for our purposes, + * including shortSource and href. + * - CssRule a more useful API to a DOM CSSRule including access to the group + * of CssSelectors that the rule provides properties for + * - CssSelector A single selector - i.e. not a selector group. In other words + * a CssSelector does not contain ','. This terminology is different from the + * standard DOM API, but more inline with the definition in the spec. + * + * - CssPropertyInfo contains style information for a single property for the + * highlighted element. + * - CssSelectorInfo is a wrapper around CssSelector, which adds sorting with + * reference to the selected element. + */ + +"use strict"; + +const nodeConstants = require("resource://devtools/shared/dom-node-constants.js"); +const { + getBindingElementAndPseudo, + getCSSStyleRules, + l10n, + hasVisitedState, + isAgentStylesheet, + isAuthorStylesheet, + isUserStylesheet, + shortSource, + FILTER, + STATUS, +} = require("resource://devtools/shared/inspector/css-logic.js"); + +const COMPAREMODE = { + BOOLEAN: "bool", + INTEGER: "int", +}; + +class CssLogic { + constructor() { + this._propertyInfos = {}; + } + + // Both setup by highlight(). + viewedElement = null; + viewedDocument = null; + + // The cache of the known sheets. + _sheets = null; + + // Have the sheets been cached? + _sheetsCached = false; + + // The total number of rules, in all stylesheets, after filtering. + _ruleCount = 0; + + // The computed styles for the viewedElement. + _computedStyle = null; + + // Source filter. Only display properties coming from the given source + _sourceFilter = FILTER.USER; + + // Used for tracking unique CssSheet/CssRule/CssSelector objects, in a run of + // processMatchedSelectors(). + _passId = 0; + + // Used for tracking matched CssSelector objects. + _matchId = 0; + + _matchedRules = null; + _matchedSelectors = null; + + // Cached keyframes rules in all stylesheets + _keyframesRules = null; + + /** + * Reset various properties + */ + reset() { + this._propertyInfos = {}; + this._ruleCount = 0; + this._sheetIndex = 0; + this._sheets = {}; + this._sheetsCached = false; + this._matchedRules = null; + this._matchedSelectors = null; + this._keyframesRules = []; + } + + /** + * Focus on a new element - remove the style caches. + * + * @param {Element} aViewedElement the element the user has highlighted + * in the Inspector. + */ + highlight(viewedElement) { + if (!viewedElement) { + this.viewedElement = null; + this.viewedDocument = null; + this._computedStyle = null; + this.reset(); + return; + } + + if (viewedElement === this.viewedElement) { + return; + } + + this.viewedElement = viewedElement; + + const doc = this.viewedElement.ownerDocument; + if (doc != this.viewedDocument) { + // New document: clear/rebuild the cache. + this.viewedDocument = doc; + + // Hunt down top level stylesheets, and cache them. + this._cacheSheets(); + } else { + // Clear cached data in the CssPropertyInfo objects. + this._propertyInfos = {}; + } + + this._matchedRules = null; + this._matchedSelectors = null; + this._computedStyle = CssLogic.getComputedStyle(this.viewedElement); + } + + /** + * Get the values of all the computed CSS properties for the highlighted + * element. + * @returns {object} The computed CSS properties for a selected element + */ + get computedStyle() { + return this._computedStyle; + } + + /** + * Get the source filter. + * @returns {string} The source filter being used. + */ + get sourceFilter() { + return this._sourceFilter; + } + + /** + * Source filter. Only display properties coming from the given source (web + * address). Note that in order to avoid information overload we DO NOT show + * unmatched system rules. + * @see FILTER.* + */ + set sourceFilter(value) { + const oldValue = this._sourceFilter; + this._sourceFilter = value; + + let ruleCount = 0; + + // Update the CssSheet objects. + this.forEachSheet(function (sheet) { + if (sheet.authorSheet && sheet.sheetAllowed) { + ruleCount += sheet.ruleCount; + } + }, this); + + this._ruleCount = ruleCount; + + // Full update is needed because the this.processMatchedSelectors() method + // skips UA stylesheets if the filter does not allow such sheets. + const needFullUpdate = oldValue == FILTER.UA || value == FILTER.UA; + + if (needFullUpdate) { + this._matchedRules = null; + this._matchedSelectors = null; + this._propertyInfos = {}; + } else { + // Update the CssPropertyInfo objects. + for (const property in this._propertyInfos) { + this._propertyInfos[property].needRefilter = true; + } + } + } + + /** + * Return a CssPropertyInfo data structure for the currently viewed element + * and the specified CSS property. If there is no currently viewed element we + * return an empty object. + * + * @param {string} property The CSS property to look for. + * @return {CssPropertyInfo} a CssPropertyInfo structure for the given + * property. + */ + getPropertyInfo(property) { + if (!this.viewedElement) { + return {}; + } + + let info = this._propertyInfos[property]; + if (!info) { + info = new CssPropertyInfo(this, property); + this._propertyInfos[property] = info; + } + + return info; + } + + /** + * Cache all the stylesheets in the inspected document + * @private + */ + _cacheSheets() { + this._passId++; + this.reset(); + + // styleSheets isn't an array, but forEach can work on it anyway + const styleSheets = InspectorUtils.getAllStyleSheets( + this.viewedDocument, + true + ); + Array.prototype.forEach.call(styleSheets, this._cacheSheet, this); + + this._sheetsCached = true; + } + + /** + * Cache a stylesheet if it falls within the requirements: if it's enabled, + * and if the @media is allowed. This method also walks through the stylesheet + * cssRules to find @imported rules, to cache the stylesheets of those rules + * as well. In addition, the @keyframes rules in the stylesheet are cached. + * + * @private + * @param {CSSStyleSheet} domSheet the CSSStyleSheet object to cache. + */ + _cacheSheet(domSheet) { + if (domSheet.disabled) { + return; + } + + // Only work with stylesheets that have their media allowed. + if (!this.mediaMatches(domSheet)) { + return; + } + + // Cache the sheet. + const cssSheet = this.getSheet(domSheet, this._sheetIndex++); + if (cssSheet._passId != this._passId) { + cssSheet._passId = this._passId; + + // Find import and keyframes rules. + for (const aDomRule of cssSheet.getCssRules()) { + if ( + aDomRule.type == CSSRule.IMPORT_RULE && + aDomRule.styleSheet && + this.mediaMatches(aDomRule) + ) { + this._cacheSheet(aDomRule.styleSheet); + } else if (aDomRule.type == CSSRule.KEYFRAMES_RULE) { + this._keyframesRules.push(aDomRule); + } + } + } + } + + /** + * Retrieve the list of stylesheets in the document. + * + * @return {array} the list of stylesheets in the document. + */ + get sheets() { + if (!this._sheetsCached) { + this._cacheSheets(); + } + + const sheets = []; + this.forEachSheet(function (sheet) { + if (sheet.authorSheet) { + sheets.push(sheet); + } + }, this); + + return sheets; + } + + /** + * Retrieve the list of keyframes rules in the document. + * + * @ return {array} the list of keyframes rules in the document. + */ + get keyframesRules() { + if (!this._sheetsCached) { + this._cacheSheets(); + } + return this._keyframesRules; + } + + /** + * Retrieve a CssSheet object for a given a CSSStyleSheet object. If the + * stylesheet is already cached, you get the existing CssSheet object, + * otherwise the new CSSStyleSheet object is cached. + * + * @param {CSSStyleSheet} domSheet the CSSStyleSheet object you want. + * @param {number} index the index, within the document, of the stylesheet. + * + * @return {CssSheet} the CssSheet object for the given CSSStyleSheet object. + */ + getSheet(domSheet, index) { + let cacheId = ""; + + if (domSheet.href) { + cacheId = domSheet.href; + } else if (domSheet.associatedDocument) { + cacheId = domSheet.associatedDocument.location; + } + + let sheet = null; + let sheetFound = false; + + if (cacheId in this._sheets) { + for (sheet of this._sheets[cacheId]) { + if (sheet.domSheet === domSheet) { + if (index != -1) { + sheet.index = index; + } + sheetFound = true; + break; + } + } + } + + if (!sheetFound) { + if (!(cacheId in this._sheets)) { + this._sheets[cacheId] = []; + } + + sheet = new CssSheet(this, domSheet, index); + if (sheet.sheetAllowed && sheet.authorSheet) { + this._ruleCount += sheet.ruleCount; + } + + this._sheets[cacheId].push(sheet); + } + + return sheet; + } + + /** + * Process each cached stylesheet in the document using your callback. + * + * @param {function} callback the function you want executed for each of the + * CssSheet objects cached. + * @param {object} scope the scope you want for the callback function. scope + * will be the this object when callback executes. + */ + forEachSheet(callback, scope) { + for (const cacheId in this._sheets) { + const sheets = this._sheets[cacheId]; + for (let i = 0; i < sheets.length; i++) { + // We take this as an opportunity to clean dead sheets + try { + const sheet = sheets[i]; + // If accessing domSheet raises an exception, then the style + // sheet is a dead object. + sheet.domSheet; + callback.call(scope, sheet, i, sheets); + } catch (e) { + sheets.splice(i, 1); + i--; + } + } + } + } + + /** + + /** + * Get the number CSSRule objects in the document, counted from all of + * the stylesheets. System sheets are excluded. If a filter is active, this + * tells only the number of CSSRule objects inside the selected + * CSSStyleSheet. + * + * WARNING: This only provides an estimate of the rule count, and the results + * could change at a later date. Todo remove this + * + * @return {number} the number of CSSRule (all rules). + */ + get ruleCount() { + if (!this._sheetsCached) { + this._cacheSheets(); + } + + return this._ruleCount; + } + + /** + * Process the CssSelector objects that match the highlighted element and its + * parent elements. scope.callback() is executed for each CssSelector + * object, being passed the CssSelector object and the match status. + * + * This method also includes all of the element.style properties, for each + * highlighted element parent and for the highlighted element itself. + * + * Note that the matched selectors are cached, such that next time your + * callback is invoked for the cached list of CssSelector objects. + * + * @param {function} callback the function you want to execute for each of + * the matched selectors. + * @param {object} scope the scope you want for the callback function. scope + * will be the this object when callback executes. + */ + processMatchedSelectors(callback, scope) { + if (this._matchedSelectors) { + if (callback) { + this._passId++; + this._matchedSelectors.forEach(function (value) { + callback.call(scope, value[0], value[1]); + value[0].cssRule._passId = this._passId; + }, this); + } + return; + } + + if (!this._matchedRules) { + this._buildMatchedRules(); + } + + this._matchedSelectors = []; + this._passId++; + + for (const matchedRule of this._matchedRules) { + const [rule, status, distance] = matchedRule; + + rule.selectors.forEach(function (selector) { + if ( + selector._matchId !== this._matchId && + (selector.inlineStyle || + this.selectorMatchesElement(rule.domRule, selector.selectorIndex)) + ) { + selector._matchId = this._matchId; + this._matchedSelectors.push([selector, status, distance]); + if (callback) { + callback.call(scope, selector, status, distance); + } + } + }, this); + + rule._passId = this._passId; + } + } + + /** + * Check if the given selector matches the highlighted element or any of its + * parents. + * + * @private + * @param {DOMRule} domRule + * The DOM Rule containing the selector. + * @param {Number} idx + * The index of the selector within the DOMRule. + * @return {boolean} + * true if the given selector matches the highlighted element or any + * of its parents, otherwise false is returned. + */ + selectorMatchesElement(domRule, idx) { + let element = this.viewedElement; + do { + if (InspectorUtils.selectorMatchesElement(element, domRule, idx)) { + return true; + } + } while ( + (element = element.parentNode) && + element.nodeType === nodeConstants.ELEMENT_NODE + ); + + return false; + } + + /** + * Check if the highlighted element or it's parents have matched selectors. + * + * @param {array} aProperties The list of properties you want to check if they + * have matched selectors or not. + * @return {object} An object that tells for each property if it has matched + * selectors or not. Object keys are property names and values are booleans. + */ + hasMatchedSelectors(properties) { + if (!this._matchedRules) { + this._buildMatchedRules(); + } + + const result = {}; + + this._matchedRules.some(function (value) { + const rule = value[0]; + const status = value[1]; + properties = properties.filter(property => { + // We just need to find if a rule has this property while it matches + // the viewedElement (or its parents). + if ( + rule.getPropertyValue(property) && + (status == STATUS.MATCHED || + (status == STATUS.PARENT_MATCH && + InspectorUtils.isInheritedProperty(property))) + ) { + result[property] = true; + return false; + } + // Keep the property for the next rule. + return true; + }); + return !properties.length; + }, this); + + return result; + } + + /** + * Build the array of matched rules for the currently highlighted element. + * The array will hold rules that match the viewedElement and its parents. + * + * @private + */ + _buildMatchedRules() { + let domRules; + let element = this.viewedElement; + const filter = this.sourceFilter; + let sheetIndex = 0; + + // distance is used to tell us how close an ancestor is to an element e.g. + // 0: The rule is directly applied to the current element. + // -1: The rule is inherited from the current element's first parent. + // -2: The rule is inherited from the current element's second parent. + // etc. + let distance = 0; + + this._matchId++; + this._passId++; + this._matchedRules = []; + + if (!element) { + return; + } + + do { + const status = + this.viewedElement === element ? STATUS.MATCHED : STATUS.PARENT_MATCH; + + try { + domRules = getCSSStyleRules(element); + } catch (ex) { + console.log("CL__buildMatchedRules error: " + ex); + continue; + } + + // getCSSStyleRules can return null with a shadow DOM element. + for (const domRule of domRules || []) { + if (domRule.type !== CSSRule.STYLE_RULE) { + continue; + } + + const sheet = this.getSheet(domRule.parentStyleSheet, -1); + if (sheet._passId !== this._passId) { + sheet.index = sheetIndex++; + sheet._passId = this._passId; + } + + if (filter === FILTER.USER && !sheet.authorSheet) { + continue; + } + + const rule = sheet.getRule(domRule); + if (rule._passId === this._passId) { + continue; + } + + rule._matchId = this._matchId; + rule._passId = this._passId; + this._matchedRules.push([rule, status, distance]); + } + + // Add element.style information. + if (element.style && element.style.length) { + const rule = new CssRule(null, { style: element.style }, element); + rule._matchId = this._matchId; + rule._passId = this._passId; + this._matchedRules.push([rule, status, distance]); + } + + distance--; + } while ( + (element = element.parentNode) && + element.nodeType === nodeConstants.ELEMENT_NODE + ); + } + + /** + * Tells if the given DOM CSS object matches the current view media. + * + * @param {object} domObject The DOM CSS object to check. + * @return {boolean} True if the DOM CSS object matches the current view + * media, or false otherwise. + */ + mediaMatches(domObject) { + const mediaText = domObject.media.mediaText; + return ( + !mediaText || + this.viewedDocument.defaultView.matchMedia(mediaText).matches + ); + } +} + +/** + * If the element has an id, return '#id'. Otherwise return 'tagname[n]' where + * n is the index of this element in its siblings. + * <p>A technically more 'correct' output from the no-id case might be: + * 'tagname:nth-of-type(n)' however this is unlikely to be more understood + * and it is longer. + * + * @param {Element} element the element for which you want the short name. + * @return {string} the string to be displayed for element. + */ +CssLogic.getShortName = function (element) { + if (!element) { + return "null"; + } + if (element.id) { + return "#" + element.id; + } + let priorSiblings = 0; + let temp = element; + while ((temp = temp.previousElementSibling)) { + priorSiblings++; + } + return element.tagName + "[" + priorSiblings + "]"; +}; + +/** + * Get a string list of selectors for a given DOMRule. + * + * @param {DOMRule} domRule + * The DOMRule to parse. + * @return {Array} + * An array of string selectors. + */ +CssLogic.getSelectors = function (domRule) { + if (domRule.type !== CSSRule.STYLE_RULE) { + // Return empty array since InspectorUtils.getSelectorCount() assumes + // only STYLE_RULE type. + return []; + } + + const selectors = []; + + const len = InspectorUtils.getSelectorCount(domRule); + for (let i = 0; i < len; i++) { + const text = InspectorUtils.getSelectorText(domRule, i); + selectors.push(text); + } + return selectors; +}; + +/** + * Given a node, check to see if it is a ::before or ::after element. + * If so, return the node that is accessible from within the document + * (the parent of the anonymous node), along with which pseudo element + * it was. Otherwise, return the node itself. + * + * @returns {Object} + * - {DOMNode} node The non-anonymous node + * - {string} pseudo One of ':marker', ':before', ':after', or null. + */ +CssLogic.getBindingElementAndPseudo = getBindingElementAndPseudo; + +/** + * Get the computed style on a node. Automatically handles reading + * computed styles on a ::before/::after element by reading on the + * parent node with the proper pseudo argument. + * + * @param {Node} + * @returns {CSSStyleDeclaration} + */ +CssLogic.getComputedStyle = function (node) { + if ( + !node || + Cu.isDeadWrapper(node) || + node.nodeType !== nodeConstants.ELEMENT_NODE || + !node.ownerGlobal + ) { + return null; + } + + const { bindingElement, pseudo } = CssLogic.getBindingElementAndPseudo(node); + + // For reasons that still escape us, pseudo-elements can sometimes be "unattached" (i.e. + // not have a parentNode defined). This seems to happen when a page is reloaded while + // the inspector is open. Bailing out here ensures that the inspector does not fail at + // presenting DOM nodes and CSS styles when this happens. This is a temporary measure. + // See bug 1506792. + if (!bindingElement) { + return null; + } + + return node.ownerGlobal.getComputedStyle(bindingElement, pseudo); +}; + +/** + * Get a source for a stylesheet, taking into account embedded stylesheets + * for which we need to use document.defaultView.location.href rather than + * sheet.href + * + * @param {CSSStyleSheet} sheet the DOM object for the style sheet. + * @return {string} the address of the stylesheet. + */ +CssLogic.href = function (sheet) { + return sheet.href || sheet.associatedDocument.location; +}; + +/** + * Returns true if the given node has visited state. + */ +CssLogic.hasVisitedState = hasVisitedState; + +class CssSheet { + /** + * A safe way to access cached bits of information about a stylesheet. + * + * @constructor + * @param {CssLogic} cssLogic pointer to the CssLogic instance working with + * this CssSheet object. + * @param {CSSStyleSheet} domSheet reference to a DOM CSSStyleSheet object. + * @param {number} index tells the index/position of the stylesheet within the + * main document. + */ + constructor(cssLogic, domSheet, index) { + this._cssLogic = cssLogic; + this.domSheet = domSheet; + this.index = this.authorSheet ? index : -100 * index; + + // Cache of the sheets href. Cached by the getter. + this._href = null; + // Short version of href for use in select boxes etc. Cached by getter. + this._shortSource = null; + + // null for uncached. + this._sheetAllowed = null; + + // Cached CssRules from the given stylesheet. + this._rules = {}; + + this._ruleCount = -1; + } + + _passId = null; + _agentSheet = null; + _authorSheet = null; + _userSheet = null; + + /** + * Check if the stylesheet is an agent stylesheet (provided by the browser). + * + * @return {boolean} true if this is an agent stylesheet, false otherwise. + */ + get agentSheet() { + if (this._agentSheet === null) { + this._agentSheet = isAgentStylesheet(this.domSheet); + } + return this._agentSheet; + } + + /** + * Check if the stylesheet is an author stylesheet (provided by the content page). + * + * @return {boolean} true if this is an author stylesheet, false otherwise. + */ + get authorSheet() { + if (this._authorSheet === null) { + this._authorSheet = isAuthorStylesheet(this.domSheet); + } + return this._authorSheet; + } + + /** + * Check if the stylesheet is a user stylesheet (provided by userChrome.css or + * userContent.css). + * + * @return {boolean} true if this is a user stylesheet, false otherwise. + */ + get userSheet() { + if (this._userSheet === null) { + this._userSheet = isUserStylesheet(this.domSheet); + } + return this._userSheet; + } + + /** + * Check if the stylesheet is disabled or not. + * @return {boolean} true if this stylesheet is disabled, or false otherwise. + */ + get disabled() { + return this.domSheet.disabled; + } + + /** + * Get a source for a stylesheet, using CssLogic.href + * + * @return {string} the address of the stylesheet. + */ + get href() { + if (this._href) { + return this._href; + } + + this._href = CssLogic.href(this.domSheet); + return this._href; + } + + /** + * Create a shorthand version of the href of a stylesheet. + * + * @return {string} the shorthand source of the stylesheet. + */ + get shortSource() { + if (this._shortSource) { + return this._shortSource; + } + + this._shortSource = shortSource(this.domSheet); + return this._shortSource; + } + + /** + * Tells if the sheet is allowed or not by the current CssLogic.sourceFilter. + * + * @return {boolean} true if the stylesheet is allowed by the sourceFilter, or + * false otherwise. + */ + get sheetAllowed() { + if (this._sheetAllowed !== null) { + return this._sheetAllowed; + } + + this._sheetAllowed = true; + + const filter = this._cssLogic.sourceFilter; + if (filter === FILTER.USER && !this.authorSheet) { + this._sheetAllowed = false; + } + if (filter !== FILTER.USER && filter !== FILTER.UA) { + this._sheetAllowed = filter === this.href; + } + + return this._sheetAllowed; + } + + /** + * Retrieve the number of rules in this stylesheet. + * + * @return {number} the number of CSSRule objects in this stylesheet. + */ + get ruleCount() { + try { + return this._ruleCount > -1 ? this._ruleCount : this.getCssRules().length; + } catch (e) { + return 0; + } + } + + /** + * Retrieve the array of css rules for this stylesheet. + * + * Accessing cssRules on a stylesheet that is not completely loaded can throw a + * DOMException (Bug 625013). This wrapper will return an empty array instead. + * + * @return {Array} array of css rules. + **/ + getCssRules() { + try { + return this.domSheet.cssRules; + } catch (e) { + return []; + } + } + + /** + * Retrieve a CssRule object for the given CSSStyleRule. The CssRule object is + * cached, such that subsequent retrievals return the same CssRule object for + * the same CSSStyleRule object. + * + * @param {CSSStyleRule} aDomRule the CSSStyleRule object for which you want a + * CssRule object. + * @return {CssRule} the cached CssRule object for the given CSSStyleRule + * object. + */ + getRule(domRule) { + const cacheId = domRule.type + domRule.selectorText; + + let rule = null; + let ruleFound = false; + + if (cacheId in this._rules) { + for (rule of this._rules[cacheId]) { + if (rule.domRule === domRule) { + ruleFound = true; + break; + } + } + } + + if (!ruleFound) { + if (!(cacheId in this._rules)) { + this._rules[cacheId] = []; + } + + rule = new CssRule(this, domRule); + this._rules[cacheId].push(rule); + } + + return rule; + } + + toString() { + return "CssSheet[" + this.shortSource + "]"; + } +} + +class CssRule { + /** + * Information about a single CSSStyleRule. + * + * @param {CSSSheet|null} cssSheet the CssSheet object of the stylesheet that + * holds the CSSStyleRule. If the rule comes from element.style, set this + * argument to null. + * @param {CSSStyleRule|object} domRule the DOM CSSStyleRule for which you want + * to cache data. If the rule comes from element.style, then provide + * an object of the form: {style: element.style}. + * @param {Element} [element] If the rule comes from element.style, then this + * argument must point to the element. + * @constructor + */ + constructor(cssSheet, domRule, element) { + this._cssSheet = cssSheet; + this.domRule = domRule; + + const parentRule = domRule.parentRule; + if (parentRule && parentRule.type == CSSRule.MEDIA_RULE) { + this.mediaText = parentRule.media.mediaText; + } + + if (this._cssSheet) { + // parse domRule.selectorText on call to this.selectors + this._selectors = null; + this.line = InspectorUtils.getRelativeRuleLine(this.domRule); + this.column = InspectorUtils.getRuleColumn(this.domRule); + this.source = this._cssSheet.shortSource + ":" + this.line; + if (this.mediaText) { + this.source += " @media " + this.mediaText; + } + this.href = this._cssSheet.href; + this.authorRule = this._cssSheet.authorSheet; + this.userRule = this._cssSheet.userSheet; + this.agentRule = this._cssSheet.agentSheet; + } else if (element) { + this._selectors = [new CssSelector(this, "@element.style", 0)]; + this.line = -1; + this.source = l10n("rule.sourceElement"); + this.href = "#"; + this.authorRule = true; + this.userRule = false; + this.agentRule = false; + this.sourceElement = element; + } + } + + _passId = null; + + mediaText = ""; + + get isMediaRule() { + return !!this.mediaText; + } + + /** + * Check if the parent stylesheet is allowed by the CssLogic.sourceFilter. + * + * @return {boolean} true if the parent stylesheet is allowed by the current + * sourceFilter, or false otherwise. + */ + get sheetAllowed() { + return this._cssSheet ? this._cssSheet.sheetAllowed : true; + } + + /** + * Retrieve the parent stylesheet index/position in the viewed document. + * + * @return {number} the parent stylesheet index/position in the viewed + * document. + */ + get sheetIndex() { + return this._cssSheet ? this._cssSheet.index : 0; + } + + /** + * Retrieve the style property value from the current CSSStyleRule. + * + * @param {string} property the CSS property name for which you want the + * value. + * @return {string} the property value. + */ + getPropertyValue(property) { + return this.domRule.style.getPropertyValue(property); + } + + /** + * Retrieve the style property priority from the current CSSStyleRule. + * + * @param {string} property the CSS property name for which you want the + * priority. + * @return {string} the property priority. + */ + getPropertyPriority(property) { + return this.domRule.style.getPropertyPriority(property); + } + + /** + * Retrieve the list of CssSelector objects for each of the parsed selectors + * of the current CSSStyleRule. + * + * @return {array} the array hold the CssSelector objects. + */ + get selectors() { + if (this._selectors) { + return this._selectors; + } + + // Parse the CSSStyleRule.selectorText string. + this._selectors = []; + + if (!this.domRule.selectorText) { + return this._selectors; + } + + const selectors = CssLogic.getSelectors(this.domRule); + + for (let i = 0, len = selectors.length; i < len; i++) { + this._selectors.push(new CssSelector(this, selectors[i], i)); + } + + return this._selectors; + } + + toString() { + return "[CssRule " + this.domRule.selectorText + "]"; + } +} + +class CssSelector { + /** + * The CSS selector class allows us to document the ranking of various CSS + * selectors. + * + * @constructor + * @param {CssRule} cssRule the CssRule instance from where the selector comes. + * @param {string} selector The selector that we wish to investigate. + * @param {Number} index The index of the selector within it's rule. + */ + constructor(cssRule, selector, index) { + this.cssRule = cssRule; + this.text = selector; + this.inlineStyle = this.text == "@element.style"; + this._specificity = null; + this.selectorIndex = index; + } + + _matchId = null; + + /** + * Retrieve the CssSelector source, which is the source of the CssSheet owning + * the selector. + * + * @return {string} the selector source. + */ + get source() { + return this.cssRule.source; + } + + /** + * Retrieve the CssSelector source element, which is the source of the CssRule + * owning the selector. This is only available when the CssSelector comes from + * an element.style. + * + * @return {string} the source element selector. + */ + get sourceElement() { + return this.cssRule.sourceElement; + } + + /** + * Retrieve the address of the CssSelector. This points to the address of the + * CssSheet owning this selector. + * + * @return {string} the address of the CssSelector. + */ + get href() { + return this.cssRule.href; + } + + /** + * Check if the selector comes from an agent stylesheet (provided by the browser). + * + * @return {boolean} true if this is an agent stylesheet, false otherwise. + */ + get agentRule() { + return this.cssRule.agentRule; + } + + /** + * Check if the selector comes from an author stylesheet (provided by the content page). + * + * @return {boolean} true if this is an author stylesheet, false otherwise. + */ + get authorRule() { + return this.cssRule.authorRule; + } + + /** + * Check if the selector comes from a user stylesheet (provided by userChrome.css or + * userContent.css). + * + * @return {boolean} true if this is a user stylesheet, false otherwise. + */ + get userRule() { + return this.cssRule.userRule; + } + + /** + * Check if the parent stylesheet is allowed by the CssLogic.sourceFilter. + * + * @return {boolean} true if the parent stylesheet is allowed by the current + * sourceFilter, or false otherwise. + */ + get sheetAllowed() { + return this.cssRule.sheetAllowed; + } + + /** + * Retrieve the parent stylesheet index/position in the viewed document. + * + * @return {number} the parent stylesheet index/position in the viewed + * document. + */ + get sheetIndex() { + return this.cssRule.sheetIndex; + } + + /** + * Retrieve the line of the parent CSSStyleRule in the parent CSSStyleSheet. + * + * @return {number} the line of the parent CSSStyleRule in the parent + * stylesheet. + */ + get ruleLine() { + return this.cssRule.line; + } + + /** + * Retrieve the column of the parent CSSStyleRule in the parent CSSStyleSheet. + * + * @return {number} the column of the parent CSSStyleRule in the parent + * stylesheet. + */ + get ruleColumn() { + return this.cssRule.column; + } + + /** + * Retrieve specificity information for the current selector. + * + * @see http://www.w3.org/TR/css3-selectors/#specificity + * @see http://www.w3.org/TR/CSS2/selector.html + * + * @return {Number} The selector's specificity. + */ + get specificity() { + if (this.inlineStyle) { + // We can't ask specificity from DOMUtils as element styles don't provide + // CSSStyleRule interface DOMUtils expect. However, specificity of element + // style is constant, 1,0,0,0 or 0x40000000, just return the constant + // directly. @see http://www.w3.org/TR/CSS2/cascade.html#specificity + return 0x40000000; + } + + if (typeof this._specificity !== "number") { + this._specificity = InspectorUtils.getSpecificity( + this.cssRule.domRule, + this.selectorIndex + ); + } + + return this._specificity; + } + + toString() { + return this.text; + } +} + +class CssPropertyInfo { + /** + * A cache of information about the matched rules, selectors and values attached + * to a CSS property, for the highlighted element. + * + * The heart of the CssPropertyInfo object is the _findMatchedSelectors() + * method. This are invoked when the PropertyView tries to access the + * .matchedSelectors array. + * Results are cached, for later reuse. + * + * @param {CssLogic} cssLogic Reference to the parent CssLogic instance + * @param {string} property The CSS property we are gathering information for + * @constructor + */ + constructor(cssLogic, property) { + this._cssLogic = cssLogic; + this.property = property; + this._value = ""; + + // An array holding CssSelectorInfo objects for each of the matched selectors + // that are inside a CSS rule. Only rules that hold the this.property are + // counted. This includes rules that come from filtered stylesheets (those + // that have sheetAllowed = false). + this._matchedSelectors = null; + } + + /** + * Retrieve the computed style value for the current property, for the + * highlighted element. + * + * @return {string} the computed style value for the current property, for the + * highlighted element. + */ + get value() { + if (!this._value && this._cssLogic.computedStyle) { + try { + this._value = this._cssLogic.computedStyle.getPropertyValue( + this.property + ); + } catch (ex) { + console.log("Error reading computed style for " + this.property); + console.log(ex); + } + } + return this._value; + } + + /** + * Retrieve the array holding CssSelectorInfo objects for each of the matched + * selectors, from each of the matched rules. Only selectors coming from + * allowed stylesheets are included in the array. + * + * @return {array} the list of CssSelectorInfo objects of selectors that match + * the highlighted element and its parents. + */ + get matchedSelectors() { + if (!this._matchedSelectors) { + this._findMatchedSelectors(); + } else if (this.needRefilter) { + this._refilterSelectors(); + } + + return this._matchedSelectors; + } + + /** + * Find the selectors that match the highlighted element and its parents. + * Uses CssLogic.processMatchedSelectors() to find the matched selectors, + * passing in a reference to CssPropertyInfo._processMatchedSelector() to + * create CssSelectorInfo objects, which we then sort + * @private + */ + _findMatchedSelectors() { + this._matchedSelectors = []; + this.needRefilter = false; + + this._cssLogic.processMatchedSelectors(this._processMatchedSelector, this); + + // Sort the selectors by how well they match the given element. + this._matchedSelectors.sort(function (selectorInfo1, selectorInfo2) { + return selectorInfo1.compareTo(selectorInfo2); + }); + + // Now we know which of the matches is best, we can mark it BEST_MATCH. + if ( + this._matchedSelectors.length && + this._matchedSelectors[0].status > STATUS.UNMATCHED + ) { + this._matchedSelectors[0].status = STATUS.BEST; + } + } + + /** + * Process a matched CssSelector object. + * + * @private + * @param {CssSelector} selector the matched CssSelector object. + * @param {STATUS} status the CssSelector match status. + */ + _processMatchedSelector(selector, status, distance) { + const cssRule = selector.cssRule; + const value = cssRule.getPropertyValue(this.property); + if ( + value && + (status == STATUS.MATCHED || + (status == STATUS.PARENT_MATCH && + InspectorUtils.isInheritedProperty(this.property))) + ) { + const selectorInfo = new CssSelectorInfo( + selector, + this.property, + value, + status, + distance + ); + this._matchedSelectors.push(selectorInfo); + } + } + + /** + * Refilter the matched selectors array when the CssLogic.sourceFilter + * changes. This allows for quick filter changes. + * @private + */ + _refilterSelectors() { + const passId = ++this._cssLogic._passId; + + const iterator = function (selectorInfo) { + const cssRule = selectorInfo.selector.cssRule; + if (cssRule._passId != passId) { + cssRule._passId = passId; + } + }; + + if (this._matchedSelectors) { + this._matchedSelectors.forEach(iterator); + } + + this.needRefilter = false; + } + + toString() { + return "CssPropertyInfo[" + this.property + "]"; + } +} + +class CssSelectorInfo { + /** + * A class that holds information about a given CssSelector object. + * + * Instances of this class are given to CssHtmlTree in the array of matched + * selectors. Each such object represents a displayable row in the PropertyView + * objects. The information given by this object blends data coming from the + * CssSheet, CssRule and from the CssSelector that own this object. + * + * @param {CssSelector} selector The CssSelector object for which to + * present information. + * @param {string} property The property for which information should + * be retrieved. + * @param {string} value The property value from the CssRule that owns + * the selector. + * @param {STATUS} status The selector match status. + * @param {number} distance See CssLogic._buildMatchedRules for definition. + * @constructor + */ + constructor(selector, property, value, status, distance) { + this.selector = selector; + this.property = property; + this.status = status; + this.distance = distance; + this.value = value; + const priority = this.selector.cssRule.getPropertyPriority(this.property); + this.important = priority === "important"; + } + + /** + * Retrieve the CssSelector source, which is the source of the CssSheet owning + * the selector. + * + * @return {string} the selector source. + */ + get source() { + return this.selector.source; + } + + /** + * Retrieve the CssSelector source element, which is the source of the CssRule + * owning the selector. This is only available when the CssSelector comes from + * an element.style. + * + * @return {string} the source element selector. + */ + get sourceElement() { + return this.selector.sourceElement; + } + + /** + * Retrieve the address of the CssSelector. This points to the address of the + * CssSheet owning this selector. + * + * @return {string} the address of the CssSelector. + */ + get href() { + return this.selector.href; + } + + /** + * Check if the CssSelector comes from element.style or not. + * + * @return {boolean} true if the CssSelector comes from element.style, or + * false otherwise. + */ + get inlineStyle() { + return this.selector.inlineStyle; + } + + /** + * Retrieve specificity information for the current selector. + * + * @return {object} an object holding specificity information for the current + * selector. + */ + get specificity() { + return this.selector.specificity; + } + + /** + * Retrieve the parent stylesheet index/position in the viewed document. + * + * @return {number} the parent stylesheet index/position in the viewed + * document. + */ + get sheetIndex() { + return this.selector.sheetIndex; + } + + /** + * Check if the parent stylesheet is allowed by the CssLogic.sourceFilter. + * + * @return {boolean} true if the parent stylesheet is allowed by the current + * sourceFilter, or false otherwise. + */ + get sheetAllowed() { + return this.selector.sheetAllowed; + } + + /** + * Retrieve the line of the parent CSSStyleRule in the parent CSSStyleSheet. + * + * @return {number} the line of the parent CSSStyleRule in the parent + * stylesheet. + */ + get ruleLine() { + return this.selector.ruleLine; + } + + /** + * Retrieve the column of the parent CSSStyleRule in the parent CSSStyleSheet. + * + * @return {number} the column of the parent CSSStyleRule in the parent + * stylesheet. + */ + get ruleColumn() { + return this.selector.ruleColumn; + } + + /** + * Check if the selector comes from a browser-provided stylesheet. + * + * @return {boolean} true if the selector comes from a browser-provided + * stylesheet, or false otherwise. + */ + get agentRule() { + return this.selector.agentRule; + } + + /** + * Check if the selector comes from a webpage-provided stylesheet. + * + * @return {boolean} true if the selector comes from a webpage-provided + * stylesheet, or false otherwise. + */ + get authorRule() { + return this.selector.authorRule; + } + + /** + * Check if the selector comes from a user stylesheet (userChrome.css or + * userContent.css). + * + * @return {boolean} true if the selector comes from a webpage-provided + * stylesheet, or false otherwise. + */ + get userRule() { + return this.selector.userRule; + } + + /** + * Compare the current CssSelectorInfo instance to another instance, based on + * the CSS cascade (see https://www.w3.org/TR/css-cascade-4/#cascading): + * + * The cascade sorts declarations according to the following criteria, in + * descending order of priority: + * + * - Rules targetting a node directly must always win over rules targetting an + * ancestor. + * + * - Origin and Importance + * The origin of a declaration is based on where it comes from and its + * importance is whether or not it is declared !important (see below). For + * our purposes here we can safely ignore Transition declarations and + * Animation declarations. + * The precedence of the various origins is, in descending order: + * - Transition declarations (ignored) + * - Important user agent declarations (User-Agent & !important) + * - Important user declarations (User & !important) + * - Important author declarations (Author & !important) + * - Animation declarations (ignored) + * - Normal author declarations (Author, normal weight) + * - Normal user declarations (User, normal weight) + * - Normal user agent declarations (User-Agent, normal weight) + * + * - Specificity (see https://www.w3.org/TR/selectors/#specificity) + * - A selector’s specificity is calculated for a given element as follows: + * - count the number of ID selectors in the selector (= A) + * - count the number of class selectors, attributes selectors, and + * pseudo-classes in the selector (= B) + * - count the number of type selectors and pseudo-elements in the + * selector (= C) + * - ignore the universal selector + * - So "UL OL LI.red" has a specificity of a=0 b=1 c=3. + * + * - Order of Appearance + * - The last declaration in document order wins. For this purpose: + * - Declarations from imported style sheets are ordered as if their style + * sheets were substituted in place of the @import rule. + * - Declarations from style sheets independently linked by the + * originating document are treated as if they were concatenated in + * linking order, as determined by the host document language. + * - Declarations from style attributes are ordered according to the + * document order of the element the style attribute appears on, and are + * all placed after any style sheets. + * - We use three methods to calculate this: + * - Sheet index + * - Rule line + * - Rule column + * + * @param {CssSelectorInfo} that + * The instance to compare ourselves against. + * @return {Number} + * -1, 0, 1 depending on how that compares with this. + */ + compareTo(that) { + let current = null; + + // Rules targetting the node must always win over rules targetting a node's + // ancestor. + current = this.compare(that, "distance", COMPAREMODE.INTEGER); + if (current) { + return current; + } + + if (this.important) { + // User-Agent & !important + // User & !important + // Author & !important + for (const propName of ["agentRule", "userRule", "authorRule"]) { + current = this.compare(that, propName, COMPAREMODE.BOOLEAN); + if (current) { + return current; + } + } + } + + // Author, normal weight + // User, normal weight + // User-Agent, normal weight + for (const propName of ["authorRule", "userRule", "agentRule"]) { + current = this.compare(that, propName, COMPAREMODE.BOOLEAN); + if (current) { + return current; + } + } + + // Specificity + // Sheet index + // Rule line + // Rule column + for (const propName of [ + "specificity", + "sheetIndex", + "ruleLine", + "ruleColumn", + ]) { + current = this.compare(that, propName, COMPAREMODE.INTEGER); + if (current) { + return current; + } + } + + // A rule has been compared against itself so return 0. + return 0; + } + + compare(that, propertyName, type) { + switch (type) { + case COMPAREMODE.BOOLEAN: + if (this[propertyName] && !that[propertyName]) { + return -1; + } + if (!this[propertyName] && that[propertyName]) { + return 1; + } + break; + case COMPAREMODE.INTEGER: + if (this[propertyName] > that[propertyName]) { + return -1; + } + if (this[propertyName] < that[propertyName]) { + return 1; + } + break; + } + return 0; + } + + toString() { + return this.selector + " -> " + this.value; + } +} + +exports.CssLogic = CssLogic; +exports.CssSelector = CssSelector; diff --git a/devtools/server/actors/inspector/custom-element-watcher.js b/devtools/server/actors/inspector/custom-element-watcher.js new file mode 100644 index 0000000000..8eb57fea40 --- /dev/null +++ b/devtools/server/actors/inspector/custom-element-watcher.js @@ -0,0 +1,144 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +/** + * The CustomElementWatcher can be used to be notified if a custom element definition + * is created for a node. + * + * When a custom element is defined for a monitored name, an "element-defined" event is + * fired with the following Object argument: + * - {String} name: name of the custom element defined + * - {Set} Set of impacted node actors + */ +class CustomElementWatcher extends EventEmitter { + constructor(chromeEventHandler) { + super(); + + this.chromeEventHandler = chromeEventHandler; + this._onCustomElementDefined = this._onCustomElementDefined.bind(this); + this.chromeEventHandler.addEventListener( + "customelementdefined", + this._onCustomElementDefined + ); + + /** + * Each window keeps its own custom element registry, all of them are watched + * separately. The struture of the watchedRegistries is as follows + * + * WeakMap( + * registry -> Map ( + * name -> Set(NodeActors) + * ) + * ) + */ + this.watchedRegistries = new WeakMap(); + } + + destroy() { + this.watchedRegistries = null; + this.chromeEventHandler.removeEventListener( + "customelementdefined", + this._onCustomElementDefined + ); + } + + /** + * Watch for custom element definitions matching the name of the provided NodeActor. + */ + manageNode(nodeActor) { + if (!this._isValidNode(nodeActor)) { + return; + } + + if (!this._shouldWatchDefinition(nodeActor)) { + return; + } + + const registry = nodeActor.rawNode.ownerGlobal.customElements; + const registryMap = this._getMapForRegistry(registry); + + const name = nodeActor.rawNode.localName; + const actorsSet = this._getActorsForName(name, registryMap); + actorsSet.add(nodeActor); + } + + /** + * Stop watching the provided NodeActor. + */ + unmanageNode(nodeActor) { + if (!this._isValidNode(nodeActor)) { + return; + } + + const win = nodeActor.rawNode.ownerGlobal; + const registry = win.customElements; + const registryMap = this._getMapForRegistry(registry); + const name = nodeActor.rawNode.localName; + if (registryMap.has(name)) { + registryMap.get(name).delete(nodeActor); + } + } + + /** + * Retrieve the map of name->nodeActors for a given CustomElementsRegistry. + * Will create the map if not created yet. + */ + _getMapForRegistry(registry) { + if (!this.watchedRegistries.has(registry)) { + this.watchedRegistries.set(registry, new Map()); + } + return this.watchedRegistries.get(registry); + } + + /** + * Retrieve the set of nodeActors for a given name and registry. + * Will create the set if not created yet. + */ + _getActorsForName(name, registryMap) { + if (!registryMap.has(name)) { + registryMap.set(name, new Set()); + } + return registryMap.get(name); + } + + _shouldWatchDefinition(nodeActor) { + const doc = nodeActor.rawNode.ownerDocument; + const namespaceURI = doc.documentElement.namespaceURI; + const name = nodeActor.rawNode.localName; + const isValidName = InspectorUtils.isCustomElementName(name, namespaceURI); + + const customElements = doc.defaultView.customElements; + return isValidName && !customElements.get(name); + } + + _onCustomElementDefined(event) { + const doc = event.target; + const registry = doc.defaultView.customElements; + const registryMap = this._getMapForRegistry(registry); + + const name = event.detail; + const actors = this._getActorsForName(name, registryMap); + this.emit("element-defined", { name, actors }); + registryMap.delete(name); + } + + /** + * Some nodes (e.g. inside of <template> tags) don't have a documentElement or an + * ownerGlobal and can't be watched by this helper. + */ + _isValidNode(nodeActor) { + const node = nodeActor.rawNode; + return ( + !Cu.isDeadWrapper(node) && + node.ownerGlobal && + node.ownerDocument?.documentElement + ); + } +} + +exports.CustomElementWatcher = CustomElementWatcher; diff --git a/devtools/server/actors/inspector/document-walker.js b/devtools/server/actors/inspector/document-walker.js new file mode 100644 index 0000000000..7ced18ecd8 --- /dev/null +++ b/devtools/server/actors/inspector/document-walker.js @@ -0,0 +1,196 @@ +/* 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, + "nodeFilterConstants", + "resource://devtools/shared/dom-node-filter-constants.js" +); +loader.lazyRequireGetter( + this, + "standardTreeWalkerFilter", + "resource://devtools/server/actors/inspector/utils.js", + true +); + +// SKIP_TO_* arguments are used with the DocumentWalker, driving the strategy to use if +// the starting node is incompatible with the filter function of the walker. +const SKIP_TO_PARENT = "SKIP_TO_PARENT"; +const SKIP_TO_SIBLING = "SKIP_TO_SIBLING"; + +class DocumentWalker { + /** + * Wrapper for inDeepTreeWalker. Adds filtering to the traversal methods. + * See inDeepTreeWalker for more information about the methods. + * + * @param {DOMNode} node + * @param {Window} rootWin + * @param {Object} + * - {Function} filter + * A custom filter function Taking in a DOMNode and returning an Int. See + * WalkerActor.nodeFilter for an example. + * - {String} skipTo + * Either SKIP_TO_PARENT or SKIP_TO_SIBLING. If the provided node is not + * compatible with the filter function for this walker, try to find a compatible + * one either in the parents or in the siblings of the node. + * - {Boolean} showAnonymousContent + * Pass true to let the walker return and traverse anonymous content. + * When navigating host elements to which shadow DOM is attached, the light tree + * will be visible only to a walker with showAnonymousContent=false. The shadow + * tree will only be visible to a walker with showAnonymousContent=true. + */ + constructor( + node, + rootWin, + { + filter = standardTreeWalkerFilter, + skipTo = SKIP_TO_PARENT, + showAnonymousContent = true, + } = {} + ) { + if (Cu.isDeadWrapper(rootWin) || !rootWin.location) { + throw new Error("Got an invalid root window in DocumentWalker"); + } + + this.walker = Cc[ + "@mozilla.org/inspector/deep-tree-walker;1" + ].createInstance(Ci.inIDeepTreeWalker); + this.walker.showAnonymousContent = showAnonymousContent; + this.walker.showSubDocuments = true; + this.walker.showDocumentsAsNodes = true; + this.walker.init(rootWin.document); + this.filter = filter; + + // Make sure that the walker knows about the initial node (which could + // be skipped due to a filter). + this.walker.currentNode = this.getStartingNode(node, skipTo); + } + + get currentNode() { + return this.walker.currentNode; + } + set currentNode(val) { + this.walker.currentNode = val; + } + + parentNode() { + return this.walker.parentNode(); + } + + nextNode() { + const node = this.walker.currentNode; + if (!node) { + return null; + } + + let nextNode = this.walker.nextNode(); + while (nextNode && this.isSkippedNode(nextNode)) { + nextNode = this.walker.nextNode(); + } + + return nextNode; + } + + firstChild() { + if (!this.walker.currentNode) { + return null; + } + + let firstChild = this.walker.firstChild(); + while (firstChild && this.isSkippedNode(firstChild)) { + firstChild = this.walker.nextSibling(); + } + + return firstChild; + } + + lastChild() { + if (!this.walker.currentNode) { + return null; + } + + let lastChild = this.walker.lastChild(); + while (lastChild && this.isSkippedNode(lastChild)) { + lastChild = this.walker.previousSibling(); + } + + return lastChild; + } + + previousSibling() { + let node = this.walker.previousSibling(); + while (node && this.isSkippedNode(node)) { + node = this.walker.previousSibling(); + } + return node; + } + + nextSibling() { + let node = this.walker.nextSibling(); + while (node && this.isSkippedNode(node)) { + node = this.walker.nextSibling(); + } + return node; + } + + getStartingNode(node, skipTo) { + // Keep a reference on the starting node in case we can't find a node compatible with + // the filter. + const startingNode = node; + + if (skipTo === SKIP_TO_PARENT) { + while (node && this.isSkippedNode(node)) { + node = node.parentNode; + } + } else if (skipTo === SKIP_TO_SIBLING) { + node = this.getClosestAcceptedSibling(node); + } + + return node || startingNode; + } + + /** + * Loop on all of the provided node siblings until finding one that is compliant with + * the filter function. + */ + getClosestAcceptedSibling(node) { + if (this.filter(node) === nodeFilterConstants.FILTER_ACCEPT) { + // node is already valid, return immediately. + return node; + } + + // Loop on starting node siblings. + let previous = node; + let next = node; + while (previous || next) { + previous = previous?.previousSibling; + next = next?.nextSibling; + + if ( + previous && + this.filter(previous) === nodeFilterConstants.FILTER_ACCEPT + ) { + // A valid node was found in the previous siblings of the node. + return previous; + } + + if (next && this.filter(next) === nodeFilterConstants.FILTER_ACCEPT) { + // A valid node was found in the next siblings of the node. + return next; + } + } + + return null; + } + + isSkippedNode(node) { + return this.filter(node) === nodeFilterConstants.FILTER_SKIP; + } +} + +exports.DocumentWalker = DocumentWalker; +exports.SKIP_TO_PARENT = SKIP_TO_PARENT; +exports.SKIP_TO_SIBLING = SKIP_TO_SIBLING; diff --git a/devtools/server/actors/inspector/event-collector.js b/devtools/server/actors/inspector/event-collector.js new file mode 100644 index 0000000000..3c307e50a0 --- /dev/null +++ b/devtools/server/actors/inspector/event-collector.js @@ -0,0 +1,1062 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This file contains event collectors that are then used by developer tools in +// order to find information about events affecting an HTML element. + +"use strict"; + +const { + isAfterPseudoElement, + isBeforePseudoElement, + isMarkerPseudoElement, + isNativeAnonymous, +} = require("resource://devtools/shared/layout/utils.js"); +const Debugger = require("Debugger"); +const { + EXCLUDED_LISTENER, +} = require("resource://devtools/server/actors/inspector/constants.js"); + +// eslint-disable-next-line +const JQUERY_LIVE_REGEX = + /return typeof \w+.*.event\.triggered[\s\S]*\.event\.(dispatch|handle).*arguments/; + +const REACT_EVENT_NAMES = [ + "onAbort", + "onAnimationEnd", + "onAnimationIteration", + "onAnimationStart", + "onAuxClick", + "onBeforeInput", + "onBlur", + "onCanPlay", + "onCanPlayThrough", + "onCancel", + "onChange", + "onClick", + "onClose", + "onCompositionEnd", + "onCompositionStart", + "onCompositionUpdate", + "onContextMenu", + "onCopy", + "onCut", + "onDoubleClick", + "onDrag", + "onDragEnd", + "onDragEnter", + "onDragExit", + "onDragLeave", + "onDragOver", + "onDragStart", + "onDrop", + "onDurationChange", + "onEmptied", + "onEncrypted", + "onEnded", + "onError", + "onFocus", + "onGotPointerCapture", + "onInput", + "onInvalid", + "onKeyDown", + "onKeyPress", + "onKeyUp", + "onLoad", + "onLoadStart", + "onLoadedData", + "onLoadedMetadata", + "onLostPointerCapture", + "onMouseDown", + "onMouseEnter", + "onMouseLeave", + "onMouseMove", + "onMouseOut", + "onMouseOver", + "onMouseUp", + "onPaste", + "onPause", + "onPlay", + "onPlaying", + "onPointerCancel", + "onPointerDown", + "onPointerEnter", + "onPointerLeave", + "onPointerMove", + "onPointerOut", + "onPointerOver", + "onPointerUp", + "onProgress", + "onRateChange", + "onReset", + "onScroll", + "onSeeked", + "onSeeking", + "onSelect", + "onStalled", + "onSubmit", + "onSuspend", + "onTimeUpdate", + "onToggle", + "onTouchCancel", + "onTouchEnd", + "onTouchMove", + "onTouchStart", + "onTransitionEnd", + "onVolumeChange", + "onWaiting", + "onWheel", + "onAbortCapture", + "onAnimationEndCapture", + "onAnimationIterationCapture", + "onAnimationStartCapture", + "onAuxClickCapture", + "onBeforeInputCapture", + "onBlurCapture", + "onCanPlayCapture", + "onCanPlayThroughCapture", + "onCancelCapture", + "onChangeCapture", + "onClickCapture", + "onCloseCapture", + "onCompositionEndCapture", + "onCompositionStartCapture", + "onCompositionUpdateCapture", + "onContextMenuCapture", + "onCopyCapture", + "onCutCapture", + "onDoubleClickCapture", + "onDragCapture", + "onDragEndCapture", + "onDragEnterCapture", + "onDragExitCapture", + "onDragLeaveCapture", + "onDragOverCapture", + "onDragStartCapture", + "onDropCapture", + "onDurationChangeCapture", + "onEmptiedCapture", + "onEncryptedCapture", + "onEndedCapture", + "onErrorCapture", + "onFocusCapture", + "onGotPointerCaptureCapture", + "onInputCapture", + "onInvalidCapture", + "onKeyDownCapture", + "onKeyPressCapture", + "onKeyUpCapture", + "onLoadCapture", + "onLoadStartCapture", + "onLoadedDataCapture", + "onLoadedMetadataCapture", + "onLostPointerCaptureCapture", + "onMouseDownCapture", + "onMouseEnterCapture", + "onMouseLeaveCapture", + "onMouseMoveCapture", + "onMouseOutCapture", + "onMouseOverCapture", + "onMouseUpCapture", + "onPasteCapture", + "onPauseCapture", + "onPlayCapture", + "onPlayingCapture", + "onPointerCancelCapture", + "onPointerDownCapture", + "onPointerEnterCapture", + "onPointerLeaveCapture", + "onPointerMoveCapture", + "onPointerOutCapture", + "onPointerOverCapture", + "onPointerUpCapture", + "onProgressCapture", + "onRateChangeCapture", + "onResetCapture", + "onScrollCapture", + "onSeekedCapture", + "onSeekingCapture", + "onSelectCapture", + "onStalledCapture", + "onSubmitCapture", + "onSuspendCapture", + "onTimeUpdateCapture", + "onToggleCapture", + "onTouchCancelCapture", + "onTouchEndCapture", + "onTouchMoveCapture", + "onTouchStartCapture", + "onTransitionEndCapture", + "onVolumeChangeCapture", + "onWaitingCapture", + "onWheelCapture", +]; + +/** + * The base class that all the enent collectors should be based upon. + */ +class MainEventCollector { + /** + * We allow displaying chrome events if the page is chrome or if + * `devtools.chrome.enabled = true`. + */ + get chromeEnabled() { + if (typeof this._chromeEnabled === "undefined") { + this._chromeEnabled = Services.prefs.getBoolPref( + "devtools.chrome.enabled" + ); + } + + return this._chromeEnabled; + } + + /** + * Check if a node has any event listeners attached. Please do not override + * this method... your getListeners() implementation needs to have the + * following signature: + * `getListeners(node, {checkOnly} = {})` + * + * @param {DOMNode} node + * The not for which we want to check for event listeners. + * @return {Boolean} + * true if the node has event listeners, false otherwise. + */ + hasListeners(node) { + return this.getListeners(node, { + checkOnly: true, + }); + } + + /** + * Get all listeners for a node. This method must be overridden. + * + * @param {DOMNode} node + * The not for which we want to get event listeners. + * @param {Object} options + * An object for passing in options. + * @param {Boolean} [options.checkOnly = false] + * Don't get any listeners but return true when the first event is + * found. + * @return {Array} + * An array of event handlers. + */ + getListeners(node, { checkOnly }) { + throw new Error("You have to implement the method getListeners()!"); + } + + /** + * Get unfiltered DOM Event listeners for a node. + * NOTE: These listeners may contain invalid events and events based + * on C++ rather than JavaScript. + * + * @param {DOMNode} node + * The node for which we want to get unfiltered event listeners. + * @return {Array} + * An array of unfiltered event listeners or an empty array + */ + getDOMListeners(node) { + let listeners; + if ( + typeof node.nodeName !== "undefined" && + node.nodeName.toLowerCase() === "html" + ) { + const winListeners = + Services.els.getListenerInfoFor(node.ownerGlobal) || []; + const docElementListeners = Services.els.getListenerInfoFor(node) || []; + const docListeners = + Services.els.getListenerInfoFor(node.parentNode) || []; + + listeners = [...winListeners, ...docElementListeners, ...docListeners]; + } else { + listeners = Services.els.getListenerInfoFor(node) || []; + } + + return listeners.filter(listener => { + const obj = this.unwrap(listener.listenerObject); + return !obj || !obj[EXCLUDED_LISTENER]; + }); + } + + getJQuery(node) { + if (Cu.isDeadWrapper(node)) { + return null; + } + + const global = this.unwrap(node.ownerGlobal); + if (!global) { + return null; + } + + const hasJQuery = global.jQuery?.fn?.jquery; + + if (hasJQuery) { + return global.jQuery; + } + return null; + } + + unwrap(obj) { + return Cu.isXrayWrapper(obj) ? obj.wrappedJSObject : obj; + } + + isChromeHandler(handler) { + try { + const handlerPrincipal = Cu.getObjectPrincipal(handler); + + // Chrome codebase may register listeners on the page from a frame script or + // JSM <video> tags may also report internal listeners, but they won't be + // coming from the system principal. Instead, they will be using an expanded + // principal. + return ( + handlerPrincipal.isSystemPrincipal || + handlerPrincipal.isExpandedPrincipal + ); + } catch (e) { + // Anything from a dead object to a CSP error can leave us here so let's + // return false so that we can fail gracefully. + return false; + } + } +} + +/** + * Get or detect DOM events. These may include DOM events created by libraries + * that enable their custom events to work. At this point we are unable to + * effectively filter them as they may be proxied or wrapped. Although we know + * there is an event, we may not know the true contents until it goes + * through `processHandlerForEvent()`. + */ +class DOMEventCollector extends MainEventCollector { + getListeners(node, { checkOnly } = {}) { + const handlers = []; + const listeners = this.getDOMListeners(node); + + for (const listener of listeners) { + // Ignore listeners without a type, e.g. + // node.addEventListener("", function() {}) + if (!listener.type) { + continue; + } + + // Get the listener object, either a Function or an Object. + const obj = listener.listenerObject; + + // Ignore listeners without any listener, e.g. + // node.addEventListener("mouseover", null); + if (!obj) { + continue; + } + + let handler = null; + + // An object without a valid handleEvent is not a valid listener. + if (typeof obj === "object") { + const unwrapped = this.unwrap(obj); + if (typeof unwrapped.handleEvent === "function") { + handler = Cu.unwaiveXrays(unwrapped.handleEvent); + } + } else if (typeof obj === "function") { + // Ignore DOM events used to trigger jQuery events as they are only + // useful to the developers of the jQuery library. + if (JQUERY_LIVE_REGEX.test(obj.toString())) { + continue; + } + // Otherwise, the other valid listener type is function. + handler = obj; + } + + // Ignore listeners that have no handler. + if (!handler) { + continue; + } + + // If we shouldn't be showing chrome events due to context and this is a + // chrome handler we can ignore it. + if (!this.chromeEnabled && this.isChromeHandler(handler)) { + continue; + } + + // If this is checking if a node has any listeners then we have found one + // so return now. + if (checkOnly) { + return true; + } + + const eventInfo = { + nsIEventListenerInfo: listener, + capturing: listener.capturing, + type: listener.type, + handler, + enabled: listener.enabled, + }; + + handlers.push(eventInfo); + } + + // If this is checking if a node has any listeners then none were found so + // return false. + if (checkOnly) { + return false; + } + + return handlers; + } +} + +/** + * Get or detect jQuery events. + */ +class JQueryEventCollector extends MainEventCollector { + // eslint-disable-next-line complexity + getListeners(node, { checkOnly } = {}) { + const jQuery = this.getJQuery(node); + const handlers = []; + + // If jQuery is not on the page, if this is an anonymous node or a pseudo + // element we need to return early. + if ( + !jQuery || + isNativeAnonymous(node) || + isMarkerPseudoElement(node) || + isBeforePseudoElement(node) || + isAfterPseudoElement(node) + ) { + if (checkOnly) { + return false; + } + return handlers; + } + + let eventsObj = null; + const data = jQuery._data || jQuery.data; + + if (data) { + // jQuery 1.2+ + try { + eventsObj = data(node, "events"); + } catch (e) { + // We have no access to a JS object. This is probably due to a CORS + // violation. Using try / catch is the only way to avoid this error. + } + } else { + // JQuery 1.0 & 1.1 + let entry; + try { + entry = entry = jQuery(node)[0]; + } catch (e) { + // We have no access to a JS object. This is probably due to a CORS + // violation. Using try / catch is the only way to avoid this error. + } + + if (!entry || !entry.events) { + if (checkOnly) { + return false; + } + return handlers; + } + + eventsObj = entry.events; + } + + if (eventsObj) { + for (const [type, events] of Object.entries(eventsObj)) { + for (const [, event] of Object.entries(events)) { + // Skip events that are part of jQueries internals. + if (node.nodeType == node.DOCUMENT_NODE && event.selector) { + continue; + } + + if (typeof event === "function" || typeof event === "object") { + // If we shouldn't be showing chrome events due to context and this + // is a chrome handler we can ignore it. + const handler = event.handler || event; + if (!this.chromeEnabled && this.isChromeHandler(handler)) { + continue; + } + + if (checkOnly) { + return true; + } + + const eventInfo = { + type, + handler, + tags: "jQuery", + hide: { + capturing: true, + }, + }; + + handlers.push(eventInfo); + } + } + } + } + + if (checkOnly) { + return false; + } + return handlers; + } +} + +/** + * Get or detect jQuery live events. + */ +class JQueryLiveEventCollector extends MainEventCollector { + // eslint-disable-next-line complexity + getListeners(node, { checkOnly } = {}) { + const jQuery = this.getJQuery(node); + const handlers = []; + + if (!jQuery) { + if (checkOnly) { + return false; + } + return handlers; + } + + const data = jQuery._data || jQuery.data; + + if (data) { + // Live events are added to the document and bubble up to all elements. + // Any element matching the specified selector will trigger the live + // event. + const win = this.unwrap(node.ownerGlobal); + let events = null; + + try { + events = data(win.document, "events"); + } catch (e) { + // We have no access to a JS object. This is probably due to a CORS + // violation. Using try / catch is the only way to avoid this error. + } + + if (events) { + for (const [, eventHolder] of Object.entries(events)) { + for (const [idx, event] of Object.entries(eventHolder)) { + if (typeof idx !== "string" || isNaN(parseInt(idx, 10))) { + continue; + } + + let selector = event.selector; + + if (!selector && event.data) { + selector = event.data.selector || event.data || event.selector; + } + + if (!selector || !node.ownerDocument) { + continue; + } + + let matches; + try { + matches = node.matches && node.matches(selector); + } catch (e) { + // Invalid selector, do nothing. + } + + if (!matches) { + continue; + } + + if (typeof event === "function" || typeof event === "object") { + // If we shouldn't be showing chrome events due to context and this + // is a chrome handler we can ignore it. + const handler = event.handler || event; + if (!this.chromeEnabled && this.isChromeHandler(handler)) { + continue; + } + + if (checkOnly) { + return true; + } + const eventInfo = { + type: event.origType || event.type.substr(selector.length + 1), + handler, + tags: "jQuery,Live", + hide: { + capturing: true, + }, + }; + + if (!eventInfo.type && event.data?.live) { + eventInfo.type = event.data.live; + } + + handlers.push(eventInfo); + } + } + } + } + } + + if (checkOnly) { + return false; + } + return handlers; + } + + normalizeListener(handlerDO) { + function isFunctionInProxy(funcDO) { + // If the anonymous function is inside the |proxy| function and the + // function only has guessed atom, the guessed atom should starts with + // "proxy/". + const displayName = funcDO.displayName; + if (displayName && displayName.startsWith("proxy/")) { + return true; + } + + // If the anonymous function is inside the |proxy| function and the + // function gets name at compile time by SetFunctionName, its guessed + // atom doesn't contain "proxy/". In that case, check if the caller is + // "proxy" function, as a fallback. + const calleeDS = funcDO.environment.calleeScript; + if (!calleeDS) { + return false; + } + const calleeName = calleeDS.displayName; + return calleeName == "proxy"; + } + + function getFirstFunctionVariable(funcDO) { + // The handler function inside the |proxy| function should point the + // unwrapped function via environment variable. + const names = funcDO.environment.names(); + for (const varName of names) { + const varDO = handlerDO.environment.getVariable(varName); + if (!varDO) { + continue; + } + if (varDO.class == "Function") { + return varDO; + } + } + return null; + } + + if (!isFunctionInProxy(handlerDO)) { + return handlerDO; + } + + const MAX_NESTED_HANDLER_COUNT = 2; + for (let i = 0; i < MAX_NESTED_HANDLER_COUNT; i++) { + const funcDO = getFirstFunctionVariable(handlerDO); + if (!funcDO) { + return handlerDO; + } + + handlerDO = funcDO; + if (isFunctionInProxy(handlerDO)) { + continue; + } + break; + } + + return handlerDO; + } +} + +/** + * Get or detect React events. + */ +class ReactEventCollector extends MainEventCollector { + getListeners(node, { checkOnly } = {}) { + const handlers = []; + const props = this.getProps(node); + + if (props) { + for (const [name, prop] of Object.entries(props)) { + if (REACT_EVENT_NAMES.includes(name)) { + const listener = prop?.__reactBoundMethod || prop; + + if (typeof listener !== "function") { + continue; + } + + if (!this.chromeEnabled && this.isChromeHandler(listener)) { + continue; + } + + if (checkOnly) { + return true; + } + + const handler = { + type: name, + handler: listener, + tags: "React", + override: { + capturing: name.endsWith("Capture"), + }, + }; + + handlers.push(handler); + } + } + } + + if (checkOnly) { + return false; + } + + return handlers; + } + + getProps(node) { + node = this.unwrap(node); + + for (const key of Object.keys(node)) { + if (key.startsWith("__reactInternalInstance$")) { + const value = node[key]; + if (value.memoizedProps) { + return value.memoizedProps; // React 16 + } + return value?._currentElement?.props; // React 15 + } + } + return null; + } + + normalizeListener(handlerDO, listener) { + let functionText = ""; + + if (handlerDO.boundTargetFunction) { + handlerDO = handlerDO.boundTargetFunction; + } + + const script = handlerDO.script; + // Script might be undefined (eg for methods bound several times, see + // https://bugzilla.mozilla.org/show_bug.cgi?id=1589658) + const introScript = script?.source.introductionScript; + + // If this is a Babel transpiled function we have no access to the + // source location so we need to hide the filename and debugger + // icon. + if (introScript && introScript.displayName.endsWith("/transform.run")) { + listener.hide.debugger = true; + listener.hide.filename = true; + + if (!handlerDO.isArrowFunction) { + functionText += "function ("; + } else { + functionText += "("; + } + + functionText += handlerDO.parameterNames.join(", "); + + functionText += ") {\n"; + + const scriptSource = script.source.text; + functionText += scriptSource.substr( + script.sourceStart, + script.sourceLength + ); + + listener.override.handler = functionText; + } + + return handlerDO; + } +} + +/** + * The exposed class responsible for gathering events. + */ +class EventCollector { + constructor(targetActor) { + this.targetActor = targetActor; + + // The event collector array. Please preserve the order otherwise there will + // be multiple failing tests. + this.eventCollectors = [ + new ReactEventCollector(), + new JQueryLiveEventCollector(), + new JQueryEventCollector(), + new DOMEventCollector(), + ]; + } + + /** + * Destructor (must be called manually). + */ + destroy() { + this.eventCollectors = null; + } + + /** + * Iterate through all event collectors returning on the first found event. + * + * @param {DOMNode} node + * The node to be checked for events. + * @return {Boolean} + * True if the node has event listeners, false otherwise. + */ + hasEventListeners(node) { + for (const collector of this.eventCollectors) { + if (collector.hasListeners(node)) { + return true; + } + } + + return false; + } + + /** + * We allow displaying chrome events if the page is chrome or if + * `devtools.chrome.enabled = true`. + */ + get chromeEnabled() { + if (typeof this._chromeEnabled === "undefined") { + this._chromeEnabled = Services.prefs.getBoolPref( + "devtools.chrome.enabled" + ); + } + + return this._chromeEnabled; + } + + /** + * + * @param {DOMNode} node + * The node for which events are to be gathered. + * @return {Array<Object>} + * An array containing objects in the following format: + * { + * {String} type: The event type, e.g. "click" + * {Function} handler: The function called when event is triggered. + * {Boolean} enabled: Whether the listener is enabled or not (event listeners can + * be disabled via the inspector) + * {String} tags: Comma separated list of tags displayed inside event bubble (e.g. "JQuery") + * {Object} hide: Flags for hiding certain properties. + * {Boolean} capturing + * } + * {Boolean} native + * {String|undefined} sourceActor: The sourceActor id of the event listener + * {nsIEventListenerInfo|undefined} nsIEventListenerInfo + * } + */ + getEventListeners(node) { + const listenerArray = []; + let dbg; + if (!this.chromeEnabled) { + dbg = new Debugger(); + } else { + // When the chrome pref is turned on, we may try to debug system compartments. + // But since bug 1517210, the server is also loaded using the system principal + // and so here, we have to ensure using a special Debugger instance, loaded + // in a compartment flagged with invisibleToDebugger=true. This helps the Debugger + // know about the precise boundary between debuggee and debugger code. + const ChromeDebugger = require("ChromeDebugger"); + dbg = new ChromeDebugger(); + } + + for (const collector of this.eventCollectors) { + const listeners = collector.getListeners(node); + + if (!listeners) { + continue; + } + + for (const listener of listeners) { + const eventObj = this.processHandlerForEvent( + listener, + dbg, + collector.normalizeListener + ); + if (eventObj) { + listenerArray.push(eventObj); + } + } + } + + listenerArray.sort((a, b) => { + return a.type.localeCompare(b.type); + }); + + return listenerArray; + } + + /** + * Process an event listener. + * + * @param {EventListener} listener + * The event listener to process. + * @param {Debugger} dbg + * Debugger instance. + * @param {Function|null} normalizeListener + * An optional function that will be called to retrieve data about the listener. + * It should be a *Collector method. + * + * @return {Array} + * An array of objects where a typical object looks like this: + * { + * type: "click", + * handler: function() { doSomething() }, + * origin: "http://www.mozilla.com", + * tags: tags, + * capturing: true, + * hide: { + * capturing: true + * }, + * native: false, + * enabled: true + * sourceActor: "sourceActor.1234", + * nsIEventListenerInfo: nsIEventListenerInfo {…}, + * } + */ + // eslint-disable-next-line complexity + processHandlerForEvent(listener, dbg, normalizeListener) { + let globalDO; + let eventObj; + + try { + const { capturing, handler } = listener; + + const global = Cu.getGlobalForObject(handler); + + // It is important that we recreate the globalDO for each handler because + // their global object can vary e.g. resource:// URLs on a video control. If + // we don't do this then all chrome listeners simply display "native code." + globalDO = dbg.addDebuggee(global); + let listenerDO = globalDO.makeDebuggeeValue(handler); + + if (normalizeListener) { + listenerDO = normalizeListener(listenerDO, listener); + } + + const hide = listener.hide || {}; + const override = listener.override || {}; + const tags = listener.tags || ""; + const type = listener.type || ""; + const enabled = !!listener.enabled; + let functionSource = handler.toString(); + let line = 0; + let column = null; + let native = false; + let url = ""; + let sourceActor = ""; + + // If the listener is an object with a 'handleEvent' method, use that. + if ( + listenerDO.class === "Object" || + /^XUL\w*Element$/.test(listenerDO.class) + ) { + let desc; + + while (!desc && listenerDO) { + desc = listenerDO.getOwnPropertyDescriptor("handleEvent"); + listenerDO = listenerDO.proto; + } + + if (desc?.value) { + listenerDO = desc.value; + } + } + + // If the listener is bound to a different context then we need to switch + // to the bound function. + if (listenerDO.isBoundFunction) { + listenerDO = listenerDO.boundTargetFunction; + } + + const { isArrowFunction, name, script, parameterNames } = listenerDO; + + if (script) { + const scriptSource = script.source.text; + + line = script.startLine; + column = script.startColumn; + url = script.url; + const actor = this.targetActor.sourcesManager.getOrCreateSourceActor( + script.source + ); + sourceActor = actor ? actor.actorID : null; + + // Checking for the string "[native code]" is the only way at this point + // to check for native code. Even if this provides a false positive then + // grabbing the source code a second time is harmless. + if ( + functionSource === "[object Object]" || + functionSource === "[object XULElement]" || + functionSource.includes("[native code]") + ) { + functionSource = scriptSource.substr( + script.sourceStart, + script.sourceLength + ); + + // At this point the script looks like this: + // () { ... } + // We prefix this with "function" if it is not a fat arrow function. + if (!isArrowFunction) { + functionSource = "function " + functionSource; + } + } + } else { + // If the listener is a native one (provided by C++ code) then we have no + // access to the script. We use the native flag to prevent showing the + // debugger button because the script is not available. + native = true; + } + + // Arrow function text always contains the parameters. Function + // parameters are often missing e.g. if Array.sort is used as a handler. + // If they are missing we provide the parameters ourselves. + if (parameterNames && parameterNames.length) { + const prefix = "function " + name + "()"; + const paramString = parameterNames.join(", "); + + if (functionSource.startsWith(prefix)) { + functionSource = functionSource.substr(prefix.length); + + functionSource = `function ${name} (${paramString})${functionSource}`; + } + } + + // If the listener is native code we display the filename "[native code]." + // This is the official string and should *not* be translated. + let origin; + if (native) { + origin = "[native code]"; + } else { + origin = + url + + (line ? ":" + line + (column === null ? "" : ":" + column) : ""); + } + + eventObj = { + type: override.type || type, + handler: override.handler || functionSource.trim(), + origin: override.origin || origin, + tags: override.tags || tags, + capturing: + typeof override.capturing !== "undefined" + ? override.capturing + : capturing, + hide: typeof override.hide !== "undefined" ? override.hide : hide, + native, + sourceActor, + nsIEventListenerInfo: listener.nsIEventListenerInfo, + enabled, + }; + + // Hide the debugger icon for DOM0 and native listeners. DOM0 listeners are + // generated dynamically from e.g. an onclick="" attribute so the script + // doesn't actually exist. + if (!sourceActor) { + eventObj.hide.debugger = true; + } + } finally { + // Ensure that we always remove the debuggee. + if (globalDO) { + dbg.removeDebuggee(globalDO); + } + } + + return eventObj; + } +} + +exports.EventCollector = EventCollector; diff --git a/devtools/server/actors/inspector/inspector.js b/devtools/server/actors/inspector/inspector.js new file mode 100644 index 0000000000..cdfa892889 --- /dev/null +++ b/devtools/server/actors/inspector/inspector.js @@ -0,0 +1,355 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Here's the server side of the remote inspector. + * + * The WalkerActor is the client's view of the debuggee's DOM. It's gives + * the client a tree of NodeActor objects. + * + * The walker presents the DOM tree mostly unmodified from the source DOM + * tree, but with a few key differences: + * + * - Empty text nodes are ignored. This is pretty typical of developer + * tools, but maybe we should reconsider that on the server side. + * - iframes with documents loaded have the loaded document as the child, + * the walker provides one big tree for the whole document tree. + * + * There are a few ways to get references to NodeActors: + * + * - When you first get a WalkerActor reference, it comes with a free + * reference to the root document's node. + * - Given a node, you can ask for children, siblings, and parents. + * - You can issue querySelector and querySelectorAll requests to find + * other elements. + * - Requests that return arbitrary nodes from the tree (like querySelector + * and querySelectorAll) will also return any nodes the client hasn't + * seen in order to have a complete set of parents. + * + * Once you have a NodeFront, you should be able to answer a few questions + * without further round trips, like the node's name, namespace/tagName, + * attributes, etc. Other questions (like a text node's full nodeValue) + * might require another round trip. + * + * The protocol guarantees that the client will always know the parent of + * any node that is returned by the server. This means that some requests + * (like querySelector) will include the extra nodes needed to satisfy this + * requirement. The client keeps track of this parent relationship, so the + * node fronts form a tree that is a subset of the actual DOM tree. + * + * + * We maintain this guarantee to support the ability to release subtrees on + * the client - when a node is disconnected from the DOM tree we want to be + * able to free the client objects for all the children nodes. + * + * So to be able to answer "all the children of a given node that we have + * seen on the client side", we guarantee that every time we've seen a node, + * we connect it up through its parents. + */ + +const { Actor } = require("resource://devtools/shared/protocol.js"); +const { + inspectorSpec, +} = require("resource://devtools/shared/specs/inspector.js"); + +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); +const { + LongStringActor, +} = require("resource://devtools/server/actors/string.js"); + +loader.lazyRequireGetter( + this, + "InspectorActorUtils", + "resource://devtools/server/actors/inspector/utils.js" +); +loader.lazyRequireGetter( + this, + "WalkerActor", + "resource://devtools/server/actors/inspector/walker.js", + true +); +loader.lazyRequireGetter( + this, + "EyeDropper", + "resource://devtools/server/actors/highlighters/eye-dropper.js", + true +); +loader.lazyRequireGetter( + this, + "PageStyleActor", + "resource://devtools/server/actors/page-style.js", + true +); +loader.lazyRequireGetter( + this, + ["CustomHighlighterActor", "isTypeRegistered", "HighlighterEnvironment"], + "resource://devtools/server/actors/highlighters.js", + true +); +loader.lazyRequireGetter( + this, + "CompatibilityActor", + "resource://devtools/server/actors/compatibility/compatibility.js", + true +); + +const SVG_NS = "http://www.w3.org/2000/svg"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +/** + * Server side of the inspector actor, which is used to create + * inspector-related actors, including the walker. + */ +class InspectorActor extends Actor { + constructor(conn, targetActor) { + super(conn, inspectorSpec); + this.targetActor = targetActor; + + this._onColorPicked = this._onColorPicked.bind(this); + this._onColorPickCanceled = this._onColorPickCanceled.bind(this); + this.destroyEyeDropper = this.destroyEyeDropper.bind(this); + } + + destroy() { + super.destroy(); + this.destroyEyeDropper(); + + this._compatibility = null; + this._pageStylePromise = null; + this._walkerPromise = null; + this.walker = null; + this.targetActor = null; + } + + get window() { + return this.targetActor.window; + } + + getWalker(options = {}) { + if (this._walkerPromise) { + return this._walkerPromise; + } + + this._walkerPromise = new Promise(resolve => { + const domReady = () => { + const targetActor = this.targetActor; + this.walker = new WalkerActor(this.conn, targetActor, options); + this.manage(this.walker); + this.walker.once("destroyed", () => { + this._walkerPromise = null; + this._pageStylePromise = null; + }); + resolve(this.walker); + }; + + if (this.window.document.readyState === "loading") { + // Expose an abort controller for DOMContentLoaded to remove the + // listener unconditionally, even if the race hits the timeout. + const abortController = new AbortController(); + Promise.race([ + new Promise(r => { + this.window.addEventListener("DOMContentLoaded", r, { + capture: true, + once: true, + signal: abortController.signal, + }); + }), + // The DOMContentLoaded event will never be emitted on documents stuck + // in the loading state, for instance if document.write was called + // without calling document.close. + // TODO: It is not clear why we are waiting for the event overall, see + // Bug 1766279 to actually stop listening to the event altogether. + new Promise(r => setTimeout(r, 500)), + ]) + .then(domReady) + .finally(() => abortController.abort()); + } else { + domReady(); + } + }); + + return this._walkerPromise; + } + + getPageStyle() { + if (this._pageStylePromise) { + return this._pageStylePromise; + } + + this._pageStylePromise = this.getWalker().then(walker => { + const pageStyle = new PageStyleActor(this); + this.manage(pageStyle); + return pageStyle; + }); + return this._pageStylePromise; + } + + getCompatibility() { + if (this._compatibility) { + return this._compatibility; + } + + this._compatibility = new CompatibilityActor(this); + this.manage(this._compatibility); + return this._compatibility; + } + + /** + * If consumers need to display several highlighters at the same time or + * different types of highlighters, then this method should be used, passing + * the type name of the highlighter needed as argument. + * A new instance will be created everytime the method is called, so it's up + * to the consumer to release it when it is not needed anymore + * + * @param {String} type The type of highlighter to create + * @return {Highlighter} The highlighter actor instance or null if the + * typeName passed doesn't match any available highlighter + */ + async getHighlighterByType(typeName) { + if (isTypeRegistered(typeName)) { + const highlighterActor = new CustomHighlighterActor(this, typeName); + if (highlighterActor.instance.isReady) { + await highlighterActor.instance.isReady; + } + + return highlighterActor; + } + return null; + } + + /** + * Get the node's image data if any (for canvas and img nodes). + * Returns an imageData object with the actual data being a LongStringActor + * and a size json object. + * The image data is transmitted as a base64 encoded png data-uri. + * The method rejects if the node isn't an image or if the image is missing + * + * Accepts a maxDim request parameter to resize images that are larger. This + * is important as the resizing occurs server-side so that image-data being + * transfered in the longstring back to the client will be that much smaller + */ + getImageDataFromURL(url, maxDim) { + const img = new this.window.Image(); + img.src = url; + + // imageToImageData waits for the image to load. + return InspectorActorUtils.imageToImageData(img, maxDim).then(imageData => { + return { + data: new LongStringActor(this.conn, imageData.data), + size: imageData.size, + }; + }); + } + + /** + * Resolve a URL to its absolute form, in the scope of a given content window. + * @param {String} url. + * @param {NodeActor} node If provided, the owner window of this node will be + * used to resolve the URL. Otherwise, the top-level content window will be + * used instead. + * @return {String} url. + */ + resolveRelativeURL(url, node) { + const document = InspectorActorUtils.isNodeDead(node) + ? this.window.document + : InspectorActorUtils.nodeDocument(node.rawNode); + + if (!document) { + return url; + } + + const baseURI = Services.io.newURI(document.location.href); + return Services.io.newURI(url, null, baseURI).spec; + } + + /** + * Create an instance of the eye-dropper highlighter and store it on this._eyeDropper. + * Note that for now, a new instance is created every time to deal with page navigation. + */ + createEyeDropper() { + this.destroyEyeDropper(); + this._highlighterEnv = new HighlighterEnvironment(); + this._highlighterEnv.initFromTargetActor(this.targetActor); + this._eyeDropper = new EyeDropper(this._highlighterEnv); + return this._eyeDropper.isReady; + } + + /** + * Destroy the current eye-dropper highlighter instance. + */ + destroyEyeDropper() { + if (this._eyeDropper) { + this.cancelPickColorFromPage(); + this._eyeDropper.destroy(); + this._eyeDropper = null; + this._highlighterEnv.destroy(); + this._highlighterEnv = null; + } + } + + /** + * Pick a color from the page using the eye-dropper. This method doesn't return anything + * but will cause events to be sent to the front when a color is picked or when the user + * cancels the picker. + * @param {Object} options + */ + async pickColorFromPage(options) { + await this.createEyeDropper(); + this._eyeDropper.show(this.window.document.documentElement, options); + this._eyeDropper.once("selected", this._onColorPicked); + this._eyeDropper.once("canceled", this._onColorPickCanceled); + this.targetActor.once("will-navigate", this.destroyEyeDropper); + } + + /** + * After the pickColorFromPage method is called, the only way to dismiss the eye-dropper + * highlighter is for the user to click in the page and select a color. If you need to + * dismiss the eye-dropper programatically instead, use this method. + */ + cancelPickColorFromPage() { + if (this._eyeDropper) { + this._eyeDropper.hide(); + this._eyeDropper.off("selected", this._onColorPicked); + this._eyeDropper.off("canceled", this._onColorPickCanceled); + this.targetActor.off("will-navigate", this.destroyEyeDropper); + } + } + + /** + * Check if the current document supports highlighters using a canvasFrame anonymous + * content container. + * It is impossible to detect the feature programmatically as some document types simply + * don't render the canvasFrame without throwing any error. + */ + supportsHighlighters() { + const doc = this.targetActor.window.document; + const ns = doc.documentElement.namespaceURI; + + // XUL documents do not support insertAnonymousContent(). + if (ns === XUL_NS) { + return false; + } + + // SVG documents do not render the canvasFrame (see Bug 1157592). + if (ns === SVG_NS) { + return false; + } + + return true; + } + + _onColorPicked(color) { + this.emit("color-picked", color); + } + + _onColorPickCanceled() { + this.emit("color-pick-canceled"); + } +} + +exports.InspectorActor = InspectorActor; diff --git a/devtools/server/actors/inspector/moz.build b/devtools/server/actors/inspector/moz.build new file mode 100644 index 0000000000..03c69dc9fe --- /dev/null +++ b/devtools/server/actors/inspector/moz.build @@ -0,0 +1,21 @@ +# -*- 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( + "constants.js", + "css-logic.js", + "custom-element-watcher.js", + "document-walker.js", + "event-collector.js", + "inspector.js", + "node-picker.js", + "node.js", + "utils.js", + "walker.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Inspector") diff --git a/devtools/server/actors/inspector/node-picker.js b/devtools/server/actors/inspector/node-picker.js new file mode 100644 index 0000000000..bb83946b02 --- /dev/null +++ b/devtools/server/actors/inspector/node-picker.js @@ -0,0 +1,435 @@ +/* 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, + "isRemoteBrowserElement", + "resource://devtools/shared/layout/utils.js", + true +); +loader.lazyRequireGetter( + this, + "HighlighterEnvironment", + "resource://devtools/server/actors/highlighters.js", + true +); +loader.lazyRequireGetter( + this, + "RemoteNodePickerNotice", + "resource://devtools/server/actors/highlighters/remote-node-picker-notice.js", + true +); + +const IS_OSX = Services.appinfo.OS === "Darwin"; + +class NodePicker { + #eventListenersAbortController; + #remoteNodePickerNoticeHighlighter; + + constructor(walker, targetActor) { + this._walker = walker; + this._targetActor = targetActor; + + this._isPicking = false; + this._hoveredNode = null; + this._currentNode = null; + + this._onHovered = this._onHovered.bind(this); + this._onKey = this._onKey.bind(this); + this._onPick = this._onPick.bind(this); + this._onSuppressedEvent = this._onSuppressedEvent.bind(this); + this._preventContentEvent = this._preventContentEvent.bind(this); + } + + get remoteNodePickerNoticeHighlighter() { + if (!this.#remoteNodePickerNoticeHighlighter) { + const env = new HighlighterEnvironment(); + env.initFromTargetActor(this._targetActor); + this.#remoteNodePickerNoticeHighlighter = new RemoteNodePickerNotice(env); + } + + return this.#remoteNodePickerNoticeHighlighter; + } + + _findAndAttachElement(event) { + // originalTarget allows access to the "real" element before any retargeting + // is applied, such as in the case of XBL anonymous elements. See also + // https://developer.mozilla.org/docs/XBL/XBL_1.0_Reference/Anonymous_Content#Event_Flow_and_Targeting + let node = event.originalTarget || event.target; + + // When holding the Shift key, search for the element at the mouse position (as opposed + // to the event target). This would make it possible to pick nodes for which we won't + // get events for (e.g. elements with `pointer-events: none`). + if (event.shiftKey) { + node = this._findNodeAtMouseEventPosition(event) || node; + } + + return this._walker.attachElement(node); + } + + /** + * Return the topmost visible element located at the event mouse position. This is + * different from retrieving the event target as it allows to retrieve elements for which + * we wouldn't have mouse event triggered (e.g. elements with `pointer-events: none`) + * + * @param {MouseEvent} event + * @returns HTMLElement + */ + _findNodeAtMouseEventPosition(event) { + const winUtils = this._targetActor.window.windowUtils; + const rectSize = 1; + const elements = winUtils.nodesFromRect( + // aX + event.clientX, + // aY + event.clientY, + // aTopSize + rectSize, + // aRightSize + rectSize, + // aBottomSize + rectSize, + // aLeftSize + rectSize, + // aIgnoreRootScrollFrame + true, + // aFlushLayout + false, + // aOnlyVisible + true, + // aTransparencyThreshold + 1 + ); + + // ⚠️ When a highlighter was added to the page (which is the case at this point), + // the first element is the html node, and might be the last one as well (See Bug 1744941). + // Until we figure this out, let's pick the second returned item when hit this. + if ( + elements.length > 1 && + ChromeUtils.getClassName(elements[0]) == "HTMLHtmlElement" + ) { + return elements[1]; + } + + return elements[0]; + } + + /** + * Returns `true` if the event was dispatched from a window included in + * the current highlighter environment; or if the highlighter environment has + * chrome privileges + * + * @param {Event} event + * The event to allow + * @return {Boolean} + */ + _isEventAllowed({ view }) { + // Allow "non multiprocess" browser toolbox to inspect documents loaded in the parent + // process (e.g. about:robots) + if (this._targetActor.window instanceof Ci.nsIDOMChromeWindow) { + return true; + } + + return this._targetActor.windows.includes(view); + } + + /** + * Returns true if the passed event original target is in the RemoteNodePickerNotice. + * + * @param {Event} event + * @returns {Boolean} + */ + _isEventInRemoteNodePickerNotice(event) { + return ( + this.#remoteNodePickerNoticeHighlighter && + event.originalTarget?.closest?.( + `#${this.#remoteNodePickerNoticeHighlighter.rootElementId}` + ) + ); + } + + /** + * Pick a node on click. + * + * This method doesn't respond anything interesting, however, it starts + * mousemove, and click listeners on the content document to fire + * events and let connected clients know when nodes are hovered over or + * clicked. + * + * Once a node is picked, events will cease, and listeners will be removed. + */ + _onPick(event) { + // If the picked node is a remote frame, then we need to let the event through + // since there's a highlighter actor in that sub-frame also picking. + if (isRemoteBrowserElement(event.target)) { + return; + } + + this._preventContentEvent(event); + if (!this._isEventAllowed(event)) { + return; + } + + // If the click was done inside the node picker notice highlighter (e.g. clicking the + // close button), directly call its `onClick` method, as it doesn't have event listeners + // itself, to avoid managing events (+ suppressedEventListeners) for the same target + // from different places. + if (this._isEventInRemoteNodePickerNotice(event)) { + this.#remoteNodePickerNoticeHighlighter.onClick(event); + return; + } + + // If Ctrl (Or Cmd on OSX) is pressed, this is only a preview click. + // Send the event to the client, but don't stop picking. + if ((IS_OSX && event.metaKey) || (!IS_OSX && event.ctrlKey)) { + this._walker.emit( + "picker-node-previewed", + this._findAndAttachElement(event) + ); + return; + } + + this._stopPicking(); + + if (!this._currentNode) { + this._currentNode = this._findAndAttachElement(event); + } + + this._walker.emit("picker-node-picked", this._currentNode); + } + + _onHovered(event) { + // If the hovered node is a remote frame, then we need to let the event through + // since there's a highlighter actor in that sub-frame also picking. + if (isRemoteBrowserElement(event.target)) { + return; + } + + this._preventContentEvent(event); + if (!this._isEventAllowed(event)) { + return; + } + + // Always call remoteNodePickerNotice handleHoveredElement so the hover state can be updated + // (it doesn't have its own event listeners to avoid managing events and suppressed + // events for the same target from different places). + if (this.#remoteNodePickerNoticeHighlighter) { + this.#remoteNodePickerNoticeHighlighter.handleHoveredElement(event); + if (this._isEventInRemoteNodePickerNotice(event)) { + return; + } + } + + this._currentNode = this._findAndAttachElement(event); + if (this._hoveredNode !== this._currentNode.node) { + this._walker.emit("picker-node-hovered", this._currentNode); + this._hoveredNode = this._currentNode.node; + } + } + + _onKey(event) { + if (!this._currentNode || !this._isPicking) { + return; + } + + this._preventContentEvent(event); + if (!this._isEventAllowed(event)) { + return; + } + + let currentNode = this._currentNode.node.rawNode; + + /** + * KEY: Action/scope + * LEFT_KEY: wider or parent + * RIGHT_KEY: narrower or child + * ENTER/CARRIAGE_RETURN: Picks currentNode + * ESC/CTRL+SHIFT+C: Cancels picker, picks currentNode + */ + switch (event.keyCode) { + // Wider. + case event.DOM_VK_LEFT: + if (!currentNode.parentElement) { + return; + } + currentNode = currentNode.parentElement; + break; + + // Narrower. + case event.DOM_VK_RIGHT: + if (!currentNode.children.length) { + return; + } + + // Set firstElementChild by default + let child = currentNode.firstElementChild; + // If currentNode is parent of hoveredNode, then + // previously selected childNode is set + const hoveredNode = this._hoveredNode.rawNode; + for (const sibling of currentNode.children) { + if (sibling.contains(hoveredNode) || sibling === hoveredNode) { + child = sibling; + } + } + + currentNode = child; + break; + + // Select the element. + case event.DOM_VK_RETURN: + this._onPick(event); + return; + + // Cancel pick mode. + case event.DOM_VK_ESCAPE: + this.cancelPick(); + this._walker.emit("picker-node-canceled"); + return; + case event.DOM_VK_C: + const { altKey, ctrlKey, metaKey, shiftKey } = event; + + if ( + (IS_OSX && metaKey && altKey | shiftKey) || + (!IS_OSX && ctrlKey && shiftKey) + ) { + this.cancelPick(); + this._walker.emit("picker-node-canceled"); + } + return; + default: + return; + } + + // Store currently attached element + this._currentNode = this._walker.attachElement(currentNode); + this._walker.emit("picker-node-hovered", this._currentNode); + } + + _onSuppressedEvent(event) { + if (event.type == "mousemove") { + this._onHovered(event); + } else if (event.type == "mouseup") { + // Suppressed mousedown/mouseup events will be sent to us before they have + // been converted into click events. Just treat any mouseup as a click. + this._onPick(event); + } + } + + // In most cases, we need to prevent content events from reaching the content. This is + // needed to avoid triggering actions such as submitting forms or following links. + // In the case where the event happens on a remote frame however, we do want to let it + // through. That is because otherwise the pickers started in nested remote frames will + // never have a chance of picking their own elements. + _preventContentEvent(event) { + if (isRemoteBrowserElement(event.target)) { + return; + } + event.stopPropagation(); + event.preventDefault(); + } + + /** + * When the debugger pauses execution in a page, events will not be delivered + * to any handlers added to elements on that page. This method uses the + * document's setSuppressedEventListener interface to bypass this restriction: + * events will be delivered to the callback at times when they would + * otherwise be suppressed. The set of events delivered this way is currently + * limited to mouse events. + * + * @param callback The function to call with suppressed events, or null. + */ + _setSuppressedEventListener(callback) { + if (!this._targetActor?.window?.document) { + return; + } + + // Pass the callback to setSuppressedEventListener as an EventListener. + this._targetActor.window.document.setSuppressedEventListener( + callback ? { handleEvent: callback } : null + ); + } + + _startPickerListeners() { + const target = this._targetActor.chromeEventHandler; + this.#eventListenersAbortController = new AbortController(); + const config = { + capture: true, + signal: this.#eventListenersAbortController.signal, + }; + target.addEventListener("mousemove", this._onHovered, config); + target.addEventListener("click", this._onPick, config); + target.addEventListener("mousedown", this._preventContentEvent, config); + target.addEventListener("mouseup", this._preventContentEvent, config); + target.addEventListener("dblclick", this._preventContentEvent, config); + target.addEventListener("keydown", this._onKey, config); + target.addEventListener("keyup", this._preventContentEvent, config); + + this._setSuppressedEventListener(this._onSuppressedEvent); + } + + _stopPickerListeners() { + this._setSuppressedEventListener(null); + + if (this.#eventListenersAbortController) { + this.#eventListenersAbortController.abort(); + this.#eventListenersAbortController = null; + } + } + + _stopPicking() { + this._stopPickerListeners(); + this._isPicking = false; + this._hoveredNode = null; + if (this.#remoteNodePickerNoticeHighlighter) { + this.#remoteNodePickerNoticeHighlighter.hide(); + } + } + + cancelPick() { + if (this._targetActor.threadActor) { + this._targetActor.threadActor.showOverlay(); + } + + if (this._isPicking) { + this._stopPicking(); + } + } + + pick(doFocus = false, isLocalTab = true) { + if (this._targetActor.threadActor) { + this._targetActor.threadActor.hideOverlay(); + } + + if (this._isPicking) { + return; + } + + this._startPickerListeners(); + this._isPicking = true; + + if (doFocus) { + this._targetActor.window.focus(); + } + + if (!isLocalTab) { + this.remoteNodePickerNoticeHighlighter.show(); + } + } + + resetHoveredNodeReference() { + this._hoveredNode = null; + } + + destroy() { + this.cancelPick(); + + this._targetActor = null; + this._walker = null; + this.#remoteNodePickerNoticeHighlighter = null; + } +} + +exports.NodePicker = NodePicker; diff --git a/devtools/server/actors/inspector/node.js b/devtools/server/actors/inspector/node.js new file mode 100644 index 0000000000..a43dd22447 --- /dev/null +++ b/devtools/server/actors/inspector/node.js @@ -0,0 +1,838 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Actor } = require("resource://devtools/shared/protocol.js"); +const { + nodeSpec, + nodeListSpec, +} = require("resource://devtools/shared/specs/node.js"); + +const { + PSEUDO_CLASSES, +} = require("resource://devtools/shared/css/constants.js"); + +loader.lazyRequireGetter( + this, + ["getCssPath", "getXPath", "findCssSelector"], + "resource://devtools/shared/inspector/css-logic.js", + true +); + +loader.lazyRequireGetter( + this, + [ + "getShadowRootMode", + "isAfterPseudoElement", + "isAnonymous", + "isBeforePseudoElement", + "isDirectShadowHostChild", + "isFrameBlockedByCSP", + "isFrameWithChildTarget", + "isMarkerPseudoElement", + "isNativeAnonymous", + "isShadowHost", + "isShadowRoot", + ], + "resource://devtools/shared/layout/utils.js", + true +); + +loader.lazyRequireGetter( + this, + [ + "getBackgroundColor", + "getClosestBackgroundColor", + "getNodeDisplayName", + "imageToImageData", + "isNodeDead", + ], + "resource://devtools/server/actors/inspector/utils.js", + true +); +loader.lazyRequireGetter( + this, + "LongStringActor", + "resource://devtools/server/actors/string.js", + true +); +loader.lazyRequireGetter( + this, + "getFontPreviewData", + "resource://devtools/server/actors/utils/style-utils.js", + true +); +loader.lazyRequireGetter( + this, + "CssLogic", + "resource://devtools/server/actors/inspector/css-logic.js", + true +); +loader.lazyRequireGetter( + this, + "EventCollector", + "resource://devtools/server/actors/inspector/event-collector.js", + true +); +loader.lazyRequireGetter( + this, + "DOMHelpers", + "resource://devtools/shared/dom-helpers.js", + true +); + +const FONT_FAMILY_PREVIEW_TEXT = "The quick brown fox jumps over the lazy dog"; +const FONT_FAMILY_PREVIEW_TEXT_SIZE = 20; + +/** + * Server side of the node actor. + */ +class NodeActor extends Actor { + constructor(walker, node) { + super(walker.conn, nodeSpec); + this.walker = walker; + this.rawNode = node; + this._eventCollector = new EventCollector(this.walker.targetActor); + // Map<id -> nsIEventListenerInfo> that we maintain to be able to disable/re-enable event listeners + // The id is generated from getEventListenerInfo + this._nsIEventListenersInfo = new Map(); + + // Store the original display type and scrollable state and whether or not the node is + // displayed to track changes when reflows occur. + const wasScrollable = this.isScrollable; + + this.currentDisplayType = this.displayType; + this.wasDisplayed = this.isDisplayed; + this.wasScrollable = wasScrollable; + + if (wasScrollable) { + this.walker.updateOverflowCausingElements( + this, + this.walker.overflowCausingElementsMap + ); + } + } + + toString() { + return ( + "[NodeActor " + this.actorID + " for " + this.rawNode.toString() + "]" + ); + } + + isDocumentElement() { + return ( + this.rawNode.ownerDocument && + this.rawNode.ownerDocument.documentElement === this.rawNode + ); + } + + destroy() { + super.destroy(); + + if (this.mutationObserver) { + if (!Cu.isDeadWrapper(this.mutationObserver)) { + this.mutationObserver.disconnect(); + } + this.mutationObserver = null; + } + + if (this.slotchangeListener) { + if (!isNodeDead(this)) { + this.rawNode.removeEventListener("slotchange", this.slotchangeListener); + } + this.slotchangeListener = null; + } + + if (this._waitForFrameLoadAbortController) { + this._waitForFrameLoadAbortController.abort(); + this._waitForFrameLoadAbortController = null; + } + if (this._waitForFrameLoadIntervalId) { + clearInterval(this._waitForFrameLoadIntervalId); + this._waitForFrameLoadIntervalId = null; + } + + if (this._nsIEventListenersInfo) { + // Re-enable all event listeners that we might have disabled + for (const nsIEventListenerInfo of this._nsIEventListenersInfo.values()) { + // If event listeners/node don't exist anymore, accessing nsIEventListenerInfo.enabled + // will throw. + try { + if (!nsIEventListenerInfo.enabled) { + nsIEventListenerInfo.enabled = true; + } + } catch (e) { + // ignore + } + } + this._nsIEventListenersInfo = null; + } + + this._eventCollector.destroy(); + this._eventCollector = null; + this.rawNode = null; + this.walker = null; + } + + // Returns the JSON representation of this object over the wire. + form() { + const parentNode = this.walker.parentNode(this); + const inlineTextChild = this.walker.inlineTextChild(this); + const shadowRoot = isShadowRoot(this.rawNode); + const hostActor = shadowRoot + ? this.walker.getNode(this.rawNode.host) + : null; + + const form = { + actor: this.actorID, + host: hostActor ? hostActor.actorID : undefined, + baseURI: this.rawNode.baseURI, + parent: parentNode ? parentNode.actorID : undefined, + nodeType: this.rawNode.nodeType, + namespaceURI: this.rawNode.namespaceURI, + nodeName: this.rawNode.nodeName, + nodeValue: this.rawNode.nodeValue, + displayName: getNodeDisplayName(this.rawNode), + numChildren: this.numChildren, + inlineTextChild: inlineTextChild ? inlineTextChild.form() : undefined, + displayType: this.displayType, + isScrollable: this.isScrollable, + isTopLevelDocument: this.isTopLevelDocument, + causesOverflow: this.walker.overflowCausingElementsMap.has(this.rawNode), + + // doctype attributes + name: this.rawNode.name, + publicId: this.rawNode.publicId, + systemId: this.rawNode.systemId, + + attrs: this.writeAttrs(), + customElementLocation: this.getCustomElementLocation(), + isMarkerPseudoElement: isMarkerPseudoElement(this.rawNode), + isBeforePseudoElement: isBeforePseudoElement(this.rawNode), + isAfterPseudoElement: isAfterPseudoElement(this.rawNode), + isAnonymous: isAnonymous(this.rawNode), + isNativeAnonymous: isNativeAnonymous(this.rawNode), + isShadowRoot: shadowRoot, + shadowRootMode: getShadowRootMode(this.rawNode), + isShadowHost: isShadowHost(this.rawNode), + isDirectShadowHostChild: isDirectShadowHostChild(this.rawNode), + pseudoClassLocks: this.writePseudoClassLocks(), + mutationBreakpoints: this.walker.getMutationBreakpoints(this), + + isDisplayed: this.isDisplayed, + isInHTMLDocument: + this.rawNode.ownerDocument && + this.rawNode.ownerDocument.contentType === "text/html", + hasEventListeners: this._hasEventListeners, + traits: {}, + }; + + if (this.isDocumentElement()) { + form.isDocumentElement = true; + } + + if (isFrameBlockedByCSP(this.rawNode)) { + form.numChildren = 0; + } + + // Flag the node if a different walker is needed to retrieve its children (i.e. if + // this is a remote frame, or if it's an iframe and we're creating targets for every iframes) + if (this.useChildTargetToFetchChildren) { + form.useChildTargetToFetchChildren = true; + // Declare at least one child (the #document element) so + // that they can be expanded. + form.numChildren = 1; + } + form.browsingContextID = this.rawNode.browsingContext?.id; + + return form; + } + + /** + * Watch the given document node for mutations using the DOM observer + * API. + */ + watchDocument(doc, callback) { + if (!doc.defaultView) { + return; + } + + const node = this.rawNode; + // Create the observer on the node's actor. The node will make sure + // the observer is cleaned up when the actor is released. + const observer = new doc.defaultView.MutationObserver(callback); + observer.mergeAttributeRecords = true; + observer.observe(node, { + attributes: true, + characterData: true, + characterDataOldValue: true, + childList: true, + subtree: true, + chromeOnlyNodes: true, + }); + this.mutationObserver = observer; + } + + /** + * Watch for all "slotchange" events on the node. + */ + watchSlotchange(callback) { + this.slotchangeListener = callback; + this.rawNode.addEventListener("slotchange", this.slotchangeListener); + } + + /** + * Check if the current node represents an element (e.g. an iframe) which has a dedicated + * target for its underlying document that we would need to use to fetch the child nodes. + * This will be the case for iframes if EFT is enabled, or if this is a remote iframe and + * fission is enabled. + */ + get useChildTargetToFetchChildren() { + return isFrameWithChildTarget(this.walker.targetActor, this.rawNode); + } + + get isTopLevelDocument() { + return this.rawNode === this.walker.rootDoc; + } + + // Estimate the number of children that the walker will return without making + // a call to children() if possible. + get numChildren() { + // For pseudo elements, childNodes.length returns 1, but the walker + // will return 0. + if ( + isMarkerPseudoElement(this.rawNode) || + isBeforePseudoElement(this.rawNode) || + isAfterPseudoElement(this.rawNode) + ) { + return 0; + } + + const rawNode = this.rawNode; + let numChildren = rawNode.childNodes.length; + const hasContentDocument = rawNode.contentDocument; + const hasSVGDocument = rawNode.getSVGDocument && rawNode.getSVGDocument(); + if (numChildren === 0 && (hasContentDocument || hasSVGDocument)) { + // This might be an iframe with virtual children. + numChildren = 1; + } + + // Normal counting misses ::before/::after. Also, some anonymous children + // may ultimately be skipped, so we have to consult with the walker. + // + // FIXME: We should be able to just check <slot> rather than + // containingShadowRoot. + if ( + numChildren === 0 || + isShadowHost(this.rawNode) || + this.rawNode.containingShadowRoot + ) { + numChildren = this.walker.countChildren(this); + } + + return numChildren; + } + + get computedStyle() { + if (!this._computedStyle) { + this._computedStyle = CssLogic.getComputedStyle(this.rawNode); + } + return this._computedStyle; + } + + /** + * Returns the computed display style property value of the node. + */ + get displayType() { + // Consider all non-element nodes as displayed. + if (isNodeDead(this) || this.rawNode.nodeType !== Node.ELEMENT_NODE) { + return null; + } + + const style = this.computedStyle; + if (!style) { + return null; + } + + let display = null; + try { + display = style.display; + } catch (e) { + // Fails for <scrollbar> elements. + } + + if ( + (display === "grid" || display === "inline-grid") && + (style.gridTemplateRows.startsWith("subgrid") || + style.gridTemplateColumns.startsWith("subgrid")) + ) { + display = "subgrid"; + } + + return display; + } + + /** + * Check whether the node currently has scrollbars and is scrollable. + */ + get isScrollable() { + return ( + this.rawNode.nodeType === Node.ELEMENT_NODE && + this.rawNode.hasVisibleScrollbars + ); + } + + /** + * Is the node currently displayed? + */ + get isDisplayed() { + const type = this.displayType; + + // Consider all non-elements or elements with no display-types to be displayed. + if (!type) { + return true; + } + + // Otherwise consider elements to be displayed only if their display-types is other + // than "none"". + return type !== "none"; + } + + /** + * Are there event listeners that are listening on this node? This method + * uses all parsers registered via event-parsers.js.registerEventParser() to + * check if there are any event listeners. + */ + get _hasEventListeners() { + // We need to pass a debugger instance from this compartment because + // otherwise we can't make use of it inside the event-collector module. + const dbg = this.getParent().targetActor.makeDebugger(); + return this._eventCollector.hasEventListeners(this.rawNode, dbg); + } + + writeAttrs() { + // If the node has no attributes or this.rawNode is the document node and a + // node with `name="attributes"` exists in the DOM we need to bail. + if ( + !this.rawNode.attributes || + !NamedNodeMap.isInstance(this.rawNode.attributes) + ) { + return undefined; + } + + return [...this.rawNode.attributes].map(attr => { + return { namespace: attr.namespace, name: attr.name, value: attr.value }; + }); + } + + writePseudoClassLocks() { + if (this.rawNode.nodeType !== Node.ELEMENT_NODE) { + return undefined; + } + let ret = undefined; + for (const pseudo of PSEUDO_CLASSES) { + if (InspectorUtils.hasPseudoClassLock(this.rawNode, pseudo)) { + ret = ret || []; + ret.push(pseudo); + } + } + return ret; + } + + /** + * Retrieve the script location of the custom element definition for this node, when + * relevant. To be linked to a custom element definition + */ + getCustomElementLocation() { + // Get a reference to the custom element definition function. + const name = this.rawNode.localName; + + if (!this.rawNode.ownerGlobal) { + return undefined; + } + + const customElementsRegistry = this.rawNode.ownerGlobal.customElements; + const customElement = + customElementsRegistry && customElementsRegistry.get(name); + if (!customElement) { + return undefined; + } + // Create debugger object for the customElement function. + const global = Cu.getGlobalForObject(customElement); + + const dbg = this.getParent().targetActor.makeDebugger(); + + // If we hit a <browser> element of Firefox, its global will be the chrome window + // which is system principal and will be in the same compartment as the debuggee. + // For some reason, this happens when we run the content toolbox. As for the content + // toolboxes, the modules are loaded in the same compartment as the <browser> element, + // this throws as the debugger can _not_ be in the same compartment as the debugger. + // This happens when we toggle fission for content toolbox because we try to reparent + // the Walker of the tab. This happens because we do not detect in Walker.reparentRemoteFrame + // that the target of the tab is the top level. That's because the target is a WindowGlobalTargetActor + // which is retrieved via Node.getEmbedderElement and doesn't return the LocalTabTargetActor. + // We should probably work on TabDescriptor so that the LocalTabTargetActor has a descriptor, + // and see if we can possibly move the local tab specific out of the TargetActor and have + // the TabDescriptor expose a pure WindowGlobalTargetActor?? (See bug 1579042) + if (Cu.getObjectPrincipal(global) == Cu.getObjectPrincipal(dbg)) { + return undefined; + } + + const globalDO = dbg.addDebuggee(global); + const customElementDO = globalDO.makeDebuggeeValue(customElement); + + // Return undefined if we can't find a script for the custom element definition. + if (!customElementDO.script) { + return undefined; + } + + return { + url: customElementDO.script.url, + line: customElementDO.script.startLine, + column: customElementDO.script.startColumn, + }; + } + + /** + * Returns a LongStringActor with the node's value. + */ + getNodeValue() { + return new LongStringActor(this.conn, this.rawNode.nodeValue || ""); + } + + /** + * Set the node's value to a given string. + */ + setNodeValue(value) { + this.rawNode.nodeValue = value; + } + + /** + * Get a unique selector string for this node. + */ + getUniqueSelector() { + if (Cu.isDeadWrapper(this.rawNode)) { + return ""; + } + return findCssSelector(this.rawNode); + } + + /** + * Get the full CSS path for this node. + * + * @return {String} A CSS selector with a part for the node and each of its ancestors. + */ + getCssPath() { + if (Cu.isDeadWrapper(this.rawNode)) { + return ""; + } + return getCssPath(this.rawNode); + } + + /** + * Get the XPath for this node. + * + * @return {String} The XPath for finding this node on the page. + */ + getXPath() { + if (Cu.isDeadWrapper(this.rawNode)) { + return ""; + } + return getXPath(this.rawNode); + } + + /** + * Scroll the selected node into view. + */ + scrollIntoView() { + this.rawNode.scrollIntoView(true); + } + + /** + * Get the node's image data if any (for canvas and img nodes). + * Returns an imageData object with the actual data being a LongStringActor + * and a size json object. + * The image data is transmitted as a base64 encoded png data-uri. + * The method rejects if the node isn't an image or if the image is missing + * + * Accepts a maxDim request parameter to resize images that are larger. This + * is important as the resizing occurs server-side so that image-data being + * transfered in the longstring back to the client will be that much smaller + */ + getImageData(maxDim) { + return imageToImageData(this.rawNode, maxDim).then(imageData => { + return { + data: new LongStringActor(this.conn, imageData.data), + size: imageData.size, + }; + }); + } + + /** + * Get all event listeners that are listening on this node. + */ + getEventListenerInfo() { + this._nsIEventListenersInfo.clear(); + + const eventListenersData = this._eventCollector.getEventListeners( + this.rawNode + ); + let counter = 0; + for (const eventListenerData of eventListenersData) { + if (eventListenerData.nsIEventListenerInfo) { + const id = `event-listener-info-${++counter}`; + this._nsIEventListenersInfo.set( + id, + eventListenerData.nsIEventListenerInfo + ); + + eventListenerData.eventListenerInfoId = id; + // remove the nsIEventListenerInfo since we don't want to send it to the client. + delete eventListenerData.nsIEventListenerInfo; + } + } + return eventListenersData; + } + + /** + * Disable a specific event listener given its associated id + * + * @param {String} eventListenerInfoId + */ + disableEventListener(eventListenerInfoId) { + const nsEventListenerInfo = + this._nsIEventListenersInfo.get(eventListenerInfoId); + if (!nsEventListenerInfo) { + throw new Error("Unkown nsEventListenerInfo"); + } + nsEventListenerInfo.enabled = false; + } + + /** + * (Re-)enable a specific event listener given its associated id + * + * @param {String} eventListenerInfoId + */ + enableEventListener(eventListenerInfoId) { + const nsEventListenerInfo = + this._nsIEventListenersInfo.get(eventListenerInfoId); + if (!nsEventListenerInfo) { + throw new Error("Unkown nsEventListenerInfo"); + } + nsEventListenerInfo.enabled = true; + } + + /** + * Modify a node's attributes. Passed an array of modifications + * similar in format to "attributes" mutations. + * { + * attributeName: <string> + * attributeNamespace: <optional string> + * newValue: <optional string> - If null or undefined, the attribute + * will be removed. + * } + * + * Returns when the modifications have been made. Mutations will + * be queued for any changes made. + */ + modifyAttributes(modifications) { + const rawNode = this.rawNode; + for (const change of modifications) { + if (change.newValue == null) { + if (change.attributeNamespace) { + rawNode.removeAttributeNS( + change.attributeNamespace, + change.attributeName + ); + } else { + rawNode.removeAttribute(change.attributeName); + } + } else if (change.attributeNamespace) { + rawNode.setAttributeDevtoolsNS( + change.attributeNamespace, + change.attributeName, + change.newValue + ); + } else { + rawNode.setAttributeDevtools(change.attributeName, change.newValue); + } + } + } + + /** + * Given the font and fill style, get the image data of a canvas with the + * preview text and font. + * Returns an imageData object with the actual data being a LongStringActor + * and the width of the text as a string. + * The image data is transmitted as a base64 encoded png data-uri. + */ + getFontFamilyDataURL(font, fillStyle = "black") { + const doc = this.rawNode.ownerDocument; + const options = { + previewText: FONT_FAMILY_PREVIEW_TEXT, + previewFontSize: FONT_FAMILY_PREVIEW_TEXT_SIZE, + fillStyle, + }; + const { dataURL, size } = getFontPreviewData(font, doc, options); + + return { data: new LongStringActor(this.conn, dataURL), size }; + } + + /** + * Finds the computed background color of the closest parent with a set background + * color. + * + * @return {String} + * String with the background color of the form rgba(r, g, b, a). Defaults to + * rgba(255, 255, 255, 1) if no background color is found. + */ + getClosestBackgroundColor() { + return getClosestBackgroundColor(this.rawNode); + } + + /** + * Finds the background color range for the parent of a single text node + * (i.e. for multi-colored backgrounds with gradients, images) or a single + * background color for single-colored backgrounds. Defaults to the closest + * background color if an error is encountered. + * + * @return {Object} + * Object with one or more of the following properties: value, min, max + */ + getBackgroundColor() { + return getBackgroundColor(this); + } + + /** + * Returns an object with the width and height of the node's owner window. + * + * @return {Object} + */ + getOwnerGlobalDimensions() { + const win = this.rawNode.ownerGlobal; + return { + innerWidth: win.innerWidth, + innerHeight: win.innerHeight, + }; + } + + /** + * If the current node is an iframe, wait for the content window to be loaded. + */ + async waitForFrameLoad() { + if (this.useChildTargetToFetchChildren) { + // If the document is handled by a dedicated target, we'll wait for a DOCUMENT_EVENT + // on the created target. + throw new Error( + "iframe content document has its own target, use that one instead" + ); + } + + if (Cu.isDeadWrapper(this.rawNode)) { + throw new Error("Node is dead"); + } + + const { contentDocument } = this.rawNode; + if (!contentDocument) { + throw new Error("Can't access contentDocument"); + } + + if (contentDocument.readyState === "uninitialized") { + // If the readyState is "uninitialized", the document is probably an about:blank + // transient document. In such case, we want to wait until the "final" document + // is inserted. + + const { chromeEventHandler } = this.rawNode.ownerGlobal.docShell; + const browsingContextID = this.rawNode.browsingContext.id; + await new Promise((resolve, reject) => { + this._waitForFrameLoadAbortController = new AbortController(); + + chromeEventHandler.addEventListener( + "DOMDocElementInserted", + e => { + const { browsingContext } = e.target.defaultView; + // Check that the document we're notified about is the iframe one. + if (browsingContext.id == browsingContextID) { + resolve(); + this._waitForFrameLoadAbortController.abort(); + } + }, + { signal: this._waitForFrameLoadAbortController.signal } + ); + + // It might happen that the "final" document will be a remote one, living in a + // different process, which means we won't get the DOMDocElementInserted event + // here, and will wait forever. To prevent this Promise to hang forever, we use + // a setInterval to check if the final document can be reached, so we can reject + // if it's not. + // This is definitely not a perfect solution, but I wasn't able to find something + // better for this feature. I think it's _fine_ as this method will be removed + // when EFT is enabled everywhere in release. + this._waitForFrameLoadIntervalId = setInterval(() => { + if (Cu.isDeadWrapper(this.rawNode) || !this.rawNode.contentDocument) { + reject("Can't access the iframe content document"); + clearInterval(this._waitForFrameLoadIntervalId); + this._waitForFrameLoadIntervalId = null; + this._waitForFrameLoadAbortController.abort(); + } + }, 50); + }); + } + + if (this.rawNode.contentDocument.readyState === "loading") { + await new Promise(resolve => { + DOMHelpers.onceDOMReady(this.rawNode.contentWindow, resolve); + }); + } + } +} + +/** + * Server side of a node list as returned by querySelectorAll() + */ +class NodeListActor extends Actor { + constructor(walker, nodeList) { + super(walker.conn, nodeListSpec); + this.walker = walker; + this.nodeList = nodeList || []; + } + + /** + * Items returned by this actor should belong to the parent walker. + */ + marshallPool() { + return this.walker; + } + + // Returns the JSON representation of this object over the wire. + form() { + return { + actor: this.actorID, + length: this.nodeList ? this.nodeList.length : 0, + }; + } + + /** + * Get a single node from the node list. + */ + item(index) { + return this.walker.attachElement(this.nodeList[index]); + } + + /** + * Get a range of the items from the node list. + */ + items(start = 0, end = this.nodeList.length) { + const items = Array.prototype.slice + .call(this.nodeList, start, end) + .map(item => this.walker._getOrCreateNodeActor(item)); + return this.walker.attachElements(items); + } + + release() {} +} + +exports.NodeActor = NodeActor; +exports.NodeListActor = NodeListActor; diff --git a/devtools/server/actors/inspector/utils.js b/devtools/server/actors/inspector/utils.js new file mode 100644 index 0000000000..88c1d45605 --- /dev/null +++ b/devtools/server/actors/inspector/utils.js @@ -0,0 +1,570 @@ +/* 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, + "colorUtils", + "resource://devtools/shared/css/color.js", + true +); +loader.lazyRequireGetter( + this, + "AsyncUtils", + "resource://devtools/shared/async-utils.js" +); +loader.lazyRequireGetter(this, "flags", "resource://devtools/shared/flags.js"); +loader.lazyRequireGetter( + this, + "DevToolsUtils", + "resource://devtools/shared/DevToolsUtils.js" +); +loader.lazyRequireGetter( + this, + "nodeFilterConstants", + "resource://devtools/shared/dom-node-filter-constants.js" +); +loader.lazyRequireGetter( + this, + ["isNativeAnonymous", "getAdjustedQuads"], + "resource://devtools/shared/layout/utils.js", + true +); +loader.lazyRequireGetter( + this, + "CssLogic", + "resource://devtools/server/actors/inspector/css-logic.js", + true +); +loader.lazyRequireGetter( + this, + "getBackgroundFor", + "resource://devtools/server/actors/accessibility/audit/contrast.js", + true +); +loader.lazyRequireGetter( + this, + ["loadSheetForBackgroundCalculation", "removeSheetForBackgroundCalculation"], + "resource://devtools/server/actors/utils/accessibility.js", + true +); +loader.lazyRequireGetter( + this, + "getTextProperties", + "resource://devtools/shared/accessibility.js", + true +); + +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const IMAGE_FETCHING_TIMEOUT = 500; + +/** + * Returns the properly cased version of the node's tag name, which can be + * used when displaying said name in the UI. + * + * @param {Node} rawNode + * Node for which we want the display name + * @return {String} + * Properly cased version of the node tag name + */ +const getNodeDisplayName = function (rawNode) { + if (rawNode.nodeName && !rawNode.localName) { + // The localName & prefix APIs have been moved from the Node interface to the Element + // interface. Use Node.nodeName as a fallback. + return rawNode.nodeName; + } + return (rawNode.prefix ? rawNode.prefix + ":" : "") + rawNode.localName; +}; + +/** + * Returns flex and grid information about a DOM node. + * In particular is it a grid flex/container and/or item? + * + * @param {DOMNode} node + * The node for which then information is required + * @return {Object} + * An object like { grid: { isContainer, isItem }, flex: { isContainer, isItem } } + */ +function getNodeGridFlexType(node) { + return { + grid: getNodeGridType(node), + flex: getNodeFlexType(node), + }; +} + +function getNodeFlexType(node) { + return { + isContainer: node.getAsFlexContainer && !!node.getAsFlexContainer(), + isItem: !!node.parentFlexElement, + }; +} + +function getNodeGridType(node) { + return { + isContainer: node.hasGridFragments && node.hasGridFragments(), + isItem: !!findGridParentContainerForNode(node), + }; +} + +function nodeDocument(node) { + if (Cu.isDeadWrapper(node)) { + return null; + } + return ( + node.ownerDocument || (node.nodeType == Node.DOCUMENT_NODE ? node : null) + ); +} + +function isNodeDead(node) { + return !node || !node.rawNode || Cu.isDeadWrapper(node.rawNode); +} + +function isInXULDocument(el) { + const doc = nodeDocument(el); + return doc?.documentElement && doc.documentElement.namespaceURI === XUL_NS; +} + +/** + * This DeepTreeWalker filter skips whitespace text nodes and anonymous + * content with the exception of ::marker, ::before, and ::after, plus anonymous + * content in XUL document (needed to show all elements in the browser toolbox). + */ +function standardTreeWalkerFilter(node) { + // ::marker, ::before, and ::after are native anonymous content, but we always + // want to show them + if ( + node.nodeName === "_moz_generated_content_marker" || + node.nodeName === "_moz_generated_content_before" || + node.nodeName === "_moz_generated_content_after" + ) { + return nodeFilterConstants.FILTER_ACCEPT; + } + + // Ignore empty whitespace text nodes that do not impact the layout. + if (isWhitespaceTextNode(node)) { + return nodeHasSize(node) + ? nodeFilterConstants.FILTER_ACCEPT + : nodeFilterConstants.FILTER_SKIP; + } + + // Ignore all native anonymous roots inside a non-XUL document. + // We need to do this to skip things like form controls, scrollbars, + // video controls, etc (see bug 1187482). + if (isNativeAnonymous(node) && !isInXULDocument(node)) { + return nodeFilterConstants.FILTER_SKIP; + } + + return nodeFilterConstants.FILTER_ACCEPT; +} + +/** + * This DeepTreeWalker filter ignores anonymous content. + */ +function noAnonymousContentTreeWalkerFilter(node) { + // Ignore all native anonymous content inside a non-XUL document. + // We need to do this to skip things like form controls, scrollbars, + // video controls, etc (see bug 1187482). + if (!isInXULDocument(node) && isNativeAnonymous(node)) { + return nodeFilterConstants.FILTER_SKIP; + } + + return nodeFilterConstants.FILTER_ACCEPT; +} +/** + * This DeepTreeWalker filter is like standardTreeWalkerFilter except that + * it also includes all anonymous content (like internal form controls). + */ +function allAnonymousContentTreeWalkerFilter(node) { + // Ignore empty whitespace text nodes that do not impact the layout. + if (isWhitespaceTextNode(node)) { + return nodeHasSize(node) + ? nodeFilterConstants.FILTER_ACCEPT + : nodeFilterConstants.FILTER_SKIP; + } + return nodeFilterConstants.FILTER_ACCEPT; +} + +/** + * Is the given node a text node composed of whitespace only? + * @param {DOMNode} node + * @return {Boolean} + */ +function isWhitespaceTextNode(node) { + return node.nodeType == Node.TEXT_NODE && !/[^\s]/.exec(node.nodeValue); +} + +/** + * Does the given node have non-0 width and height? + * @param {DOMNode} node + * @return {Boolean} + */ +function nodeHasSize(node) { + if (!node.getBoxQuads) { + return false; + } + + const quads = node.getBoxQuads({ + createFramesForSuppressedWhitespace: false, + }); + return quads.some(quad => { + const bounds = quad.getBounds(); + return bounds.width && bounds.height; + }); +} + +/** + * Returns a promise that is settled once the given HTMLImageElement has + * finished loading. + * + * @param {HTMLImageElement} image - The image element. + * @param {Number} timeout - Maximum amount of time the image is allowed to load + * before the waiting is aborted. Ignored if flags.testing is set. + * + * @return {Promise} that is fulfilled once the image has loaded. If the image + * fails to load or the load takes too long, the promise is rejected. + */ +function ensureImageLoaded(image, timeout) { + const { HTMLImageElement } = image.ownerGlobal; + if (!(image instanceof HTMLImageElement)) { + return Promise.reject("image must be an HTMLImageELement"); + } + + if (image.complete) { + // The image has already finished loading. + return Promise.resolve(); + } + + // This image is still loading. + const onLoad = AsyncUtils.listenOnce(image, "load"); + + // Reject if loading fails. + const onError = AsyncUtils.listenOnce(image, "error").then(() => { + return Promise.reject("Image '" + image.src + "' failed to load."); + }); + + // Don't timeout when testing. This is never settled. + let onAbort = new Promise(() => {}); + + if (!flags.testing) { + // Tests are not running. Reject the promise after given timeout. + onAbort = DevToolsUtils.waitForTime(timeout).then(() => { + return Promise.reject("Image '" + image.src + "' took too long to load."); + }); + } + + // See which happens first. + return Promise.race([onLoad, onError, onAbort]); +} + +/** + * Given an <img> or <canvas> element, return the image data-uri. If @param node + * is an <img> element, the method waits a while for the image to load before + * the data is generated. If the image does not finish loading in a reasonable + * time (IMAGE_FETCHING_TIMEOUT milliseconds) the process aborts. + * + * @param {HTMLImageElement|HTMLCanvasElement} node - The <img> or <canvas> + * element, or Image() object. Other types cause the method to reject. + * @param {Number} maxDim - Optionally pass a maximum size you want the longest + * side of the image to be resized to before getting the image data. + + * @return {Promise} A promise that is fulfilled with an object containing the + * data-uri and size-related information: + * { data: "...", + * size: { + * naturalWidth: 400, + * naturalHeight: 300, + * resized: true } + * }. + * + * If something goes wrong, the promise is rejected. + */ +const imageToImageData = async function (node, maxDim) { + const { HTMLCanvasElement, HTMLImageElement } = node.ownerGlobal; + + const isImg = node instanceof HTMLImageElement; + const isCanvas = node instanceof HTMLCanvasElement; + + if (!isImg && !isCanvas) { + throw new Error("node is not a <canvas> or <img> element."); + } + + if (isImg) { + // Ensure that the image is ready. + await ensureImageLoaded(node, IMAGE_FETCHING_TIMEOUT); + } + + // Get the image resize ratio if a maxDim was provided + let resizeRatio = 1; + const imgWidth = node.naturalWidth || node.width; + const imgHeight = node.naturalHeight || node.height; + const imgMax = Math.max(imgWidth, imgHeight); + if (maxDim && imgMax > maxDim) { + resizeRatio = maxDim / imgMax; + } + + // Extract the image data + let imageData; + // The image may already be a data-uri, in which case, save ourselves the + // trouble of converting via the canvas.drawImage.toDataURL method, but only + // if the image doesn't need resizing + if (isImg && node.src.startsWith("data:") && resizeRatio === 1) { + imageData = node.src; + } else { + // Create a canvas to copy the rawNode into and get the imageData from + const canvas = node.ownerDocument.createElementNS(XHTML_NS, "canvas"); + canvas.width = imgWidth * resizeRatio; + canvas.height = imgHeight * resizeRatio; + const ctx = canvas.getContext("2d"); + + // Copy the rawNode image or canvas in the new canvas and extract data + ctx.drawImage(node, 0, 0, canvas.width, canvas.height); + imageData = canvas.toDataURL("image/png"); + } + + return { + data: imageData, + size: { + naturalWidth: imgWidth, + naturalHeight: imgHeight, + resized: resizeRatio !== 1, + }, + }; +}; + +/** + * Finds the computed background color of the closest parent with a set background color. + * + * @param {DOMNode} node + * Node for which we want to find closest background color. + * @return {String} + * String with the background color of the form rgba(r, g, b, a). Defaults to + * rgba(255, 255, 255, 1) if no background color is found. + */ +function getClosestBackgroundColor(node) { + let current = node; + + while (current) { + const computedStyle = CssLogic.getComputedStyle(current); + if (computedStyle) { + const currentStyle = computedStyle.getPropertyValue("background-color"); + if (InspectorUtils.isValidCSSColor(currentStyle)) { + const currentCssColor = new colorUtils.CssColor(currentStyle); + if (!currentCssColor.isTransparent()) { + return currentCssColor.rgba; + } + } + } + + current = current.parentNode; + } + + return "rgba(255, 255, 255, 1)"; +} + +/** + * Finds the background image of the closest parent where it is set. + * + * @param {DOMNode} node + * Node for which we want to find the background image. + * @return {String} + * String with the value of the background iamge property. Defaults to "none" if + * no background image is found. + */ +function getClosestBackgroundImage(node) { + let current = node; + + while (current) { + const computedStyle = CssLogic.getComputedStyle(current); + if (computedStyle) { + const currentBackgroundImage = + computedStyle.getPropertyValue("background-image"); + if (currentBackgroundImage !== "none") { + return currentBackgroundImage; + } + } + + current = current.parentNode; + } + + return "none"; +} + +/** + * If the provided node is a grid item, then return its parent grid. + * + * @param {DOMNode} node + * The node that is supposedly a grid item. + * @return {DOMNode|null} + * The parent grid if found, null otherwise. + */ +function findGridParentContainerForNode(node) { + try { + while ((node = node.parentNode)) { + const display = node.ownerGlobal.getComputedStyle(node).display; + + if (display.includes("grid")) { + return node; + } else if (display === "contents") { + // Continue walking up the tree since the parent node is a content element. + continue; + } + + break; + } + } catch (e) { + // Getting the parentNode can fail when the supplied node is in shadow DOM. + } + + return null; +} + +/** + * Finds the background color range for the parent of a single text node + * (i.e. for multi-colored backgrounds with gradients, images) or a single + * background color for single-colored backgrounds. Defaults to the closest + * background color if an error is encountered. + * + * @param {Object} + * Node actor containing the following properties: + * {DOMNode} rawNode + * Node for which we want to calculate the color contrast. + * {WalkerActor} walker + * Walker actor used to check whether the node is the parent elm of a single text node. + * @return {Object} + * Object with one or more of the following properties: + * {Array|null} value + * RGBA array for single-colored background. Null for multi-colored backgrounds. + * {Array|null} min + * RGBA array for the min luminance color in a multi-colored background. + * Null for single-colored backgrounds. + * {Array|null} max + * RGBA array for the max luminance color in a multi-colored background. + * Null for single-colored backgrounds. + */ +async function getBackgroundColor({ rawNode: node, walker }) { + // Fall back to calculating contrast against closest bg if: + // - not element node + // - more than one child + // Avoid calculating bounds and creating doc walker by returning early. + if ( + node.nodeType != Node.ELEMENT_NODE || + node.childNodes.length > 1 || + !node.firstChild + ) { + return { + value: getClosestBackgroundColorInRGBA(node), + }; + } + + const quads = getAdjustedQuads(node.ownerGlobal, node.firstChild, "content"); + + // Fall back to calculating contrast against closest bg if there are no bounds for text node. + // Avoid creating doc walker by returning early. + if (quads.length === 0 || !quads[0].bounds) { + return { + value: getClosestBackgroundColorInRGBA(node), + }; + } + + const bounds = quads[0].bounds; + + const docWalker = walker.getDocumentWalker(node); + const firstChild = docWalker.firstChild(); + + // Fall back to calculating contrast against closest bg if: + // - more than one child + // - unique child is not a text node + if ( + !firstChild || + docWalker.nextSibling() || + firstChild.nodeType !== Node.TEXT_NODE + ) { + return { + value: getClosestBackgroundColorInRGBA(node), + }; + } + + // Try calculating complex backgrounds for node + const win = node.ownerGlobal; + loadSheetForBackgroundCalculation(win); + const computedStyle = CssLogic.getComputedStyle(node); + const props = computedStyle ? getTextProperties(computedStyle) : null; + + // Fall back to calculating contrast against closest bg if there are no text props. + if (!props) { + return { + value: getClosestBackgroundColorInRGBA(node), + }; + } + + const bgColor = await getBackgroundFor(node, { + bounds, + win, + convertBoundsRelativeToViewport: false, + size: props.size, + isBoldText: props.isBoldText, + }); + removeSheetForBackgroundCalculation(win); + + return ( + bgColor || { + value: getClosestBackgroundColorInRGBA(node), + } + ); +} + +/** + * + * @param {DOMNode} node: The node we want the background color of + * @returns {Array[r,g,b,a]} + */ +function getClosestBackgroundColorInRGBA(node) { + const { r, g, b, a } = InspectorUtils.colorToRGBA( + getClosestBackgroundColor(node) + ); + return [r, g, b, a]; +} +/** + * Indicates if a document is ready (i.e. if it's not loading anymore) + * + * @param {HTMLDocument} document: The document we want to check + * @returns {Boolean} + */ +function isDocumentReady(document) { + if (!document) { + return false; + } + + const { readyState } = document; + if (readyState == "interactive" || readyState == "complete") { + return true; + } + + // A document might stay forever in unitialized state. + // If the target actor is not currently loading a document, + // assume the document is ready. + const webProgress = document.defaultView.docShell.QueryInterface( + Ci.nsIWebProgress + ); + return !webProgress.isLoadingDocument; +} + +module.exports = { + allAnonymousContentTreeWalkerFilter, + isDocumentReady, + isWhitespaceTextNode, + findGridParentContainerForNode, + getBackgroundColor, + getClosestBackgroundColor, + getClosestBackgroundImage, + getNodeDisplayName, + getNodeGridFlexType, + imageToImageData, + isNodeDead, + nodeDocument, + standardTreeWalkerFilter, + noAnonymousContentTreeWalkerFilter, +}; diff --git a/devtools/server/actors/inspector/walker.js b/devtools/server/actors/inspector/walker.js new file mode 100644 index 0000000000..16250d1e81 --- /dev/null +++ b/devtools/server/actors/inspector/walker.js @@ -0,0 +1,2753 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Actor } = require("resource://devtools/shared/protocol.js"); +const { walkerSpec } = require("resource://devtools/shared/specs/walker.js"); + +const { + LongStringActor, +} = require("resource://devtools/server/actors/string.js"); +const { + EXCLUDED_LISTENER, +} = require("resource://devtools/server/actors/inspector/constants.js"); + +loader.lazyRequireGetter( + this, + "nodeFilterConstants", + "resource://devtools/shared/dom-node-filter-constants.js" +); + +loader.lazyRequireGetter( + this, + [ + "getFrameElement", + "isAfterPseudoElement", + "isBeforePseudoElement", + "isDirectShadowHostChild", + "isMarkerPseudoElement", + "isFrameBlockedByCSP", + "isFrameWithChildTarget", + "isShadowHost", + "isShadowRoot", + "loadSheet", + ], + "resource://devtools/shared/layout/utils.js", + true +); + +loader.lazyRequireGetter( + this, + "throttle", + "resource://devtools/shared/throttle.js", + true +); + +loader.lazyRequireGetter( + this, + [ + "allAnonymousContentTreeWalkerFilter", + "findGridParentContainerForNode", + "isNodeDead", + "noAnonymousContentTreeWalkerFilter", + "nodeDocument", + "standardTreeWalkerFilter", + ], + "resource://devtools/server/actors/inspector/utils.js", + true +); + +loader.lazyRequireGetter( + this, + "CustomElementWatcher", + "resource://devtools/server/actors/inspector/custom-element-watcher.js", + true +); +loader.lazyRequireGetter( + this, + ["DocumentWalker", "SKIP_TO_SIBLING"], + "resource://devtools/server/actors/inspector/document-walker.js", + true +); +loader.lazyRequireGetter( + this, + ["NodeActor", "NodeListActor"], + "resource://devtools/server/actors/inspector/node.js", + true +); +loader.lazyRequireGetter( + this, + "NodePicker", + "resource://devtools/server/actors/inspector/node-picker.js", + true +); +loader.lazyRequireGetter( + this, + "LayoutActor", + "resource://devtools/server/actors/layout.js", + true +); +loader.lazyRequireGetter( + this, + ["getLayoutChangesObserver", "releaseLayoutChangesObserver"], + "resource://devtools/server/actors/reflow.js", + true +); +loader.lazyRequireGetter( + this, + "WalkerSearch", + "resource://devtools/server/actors/utils/walker-search.js", + true +); + +// ContentDOMReference requires ChromeUtils, which isn't available in worker context. +const lazy = {}; +if (!isWorker) { + loader.lazyGetter( + lazy, + "ContentDOMReference", + () => + ChromeUtils.importESModule( + "resource://gre/modules/ContentDOMReference.sys.mjs", + { + // ContentDOMReference needs to be retrieved from the shared global + // since it is a shared singleton. + loadInDevToolsLoader: false, + } + ).ContentDOMReference + ); +} + +loader.lazyServiceGetter( + this, + "eventListenerService", + "@mozilla.org/eventlistenerservice;1", + "nsIEventListenerService" +); + +// Minimum delay between two "new-mutations" events. +const MUTATIONS_THROTTLING_DELAY = 100; +// List of mutation types that should -not- be throttled. +const IMMEDIATE_MUTATIONS = ["pseudoClassLock"]; + +const HIDDEN_CLASS = "__fx-devtools-hide-shortcut__"; + +// The possible completions to a ':' with added score to give certain values +// some preference. +const PSEUDO_SELECTORS = [ + [":active", 1], + [":hover", 1], + [":focus", 1], + [":visited", 0], + [":link", 0], + [":first-letter", 0], + [":first-child", 2], + [":before", 2], + [":after", 2], + [":lang(", 0], + [":not(", 3], + [":first-of-type", 0], + [":last-of-type", 0], + [":only-of-type", 0], + [":only-child", 2], + [":nth-child(", 3], + [":nth-last-child(", 0], + [":nth-of-type(", 0], + [":nth-last-of-type(", 0], + [":last-child", 2], + [":root", 0], + [":empty", 0], + [":target", 0], + [":enabled", 0], + [":disabled", 0], + [":checked", 1], + ["::selection", 0], + ["::marker", 0], +]; + +const HELPER_SHEET = + "data:text/css;charset=utf-8," + + encodeURIComponent(` + .__fx-devtools-hide-shortcut__ { + visibility: hidden !important; + } +`); + +/** + * We only send nodeValue up to a certain size by default. This stuff + * controls that size. + */ +exports.DEFAULT_VALUE_SUMMARY_LENGTH = 50; +var gValueSummaryLength = exports.DEFAULT_VALUE_SUMMARY_LENGTH; + +exports.getValueSummaryLength = function () { + return gValueSummaryLength; +}; + +exports.setValueSummaryLength = function (val) { + gValueSummaryLength = val; +}; + +/** + * Server side of the DOM walker. + */ +class WalkerActor extends Actor { + /** + * Create the WalkerActor + * @param {DevToolsServerConnection} conn + * The server connection. + * @param {TargetActor} targetActor + * The top-level Actor for this tab. + * @param {Object} options + * - {Boolean} showAllAnonymousContent: Show all native anonymous content + */ + constructor(conn, targetActor, options) { + super(conn, walkerSpec); + this.targetActor = targetActor; + this.rootWin = targetActor.window; + this.rootDoc = this.rootWin.document; + + // Map of already created node actors, keyed by their corresponding DOMNode. + this._nodeActorsMap = new Map(); + + this._pendingMutations = []; + this._activePseudoClassLocks = new Set(); + this._mutationBreakpoints = new WeakMap(); + this._anonParents = new WeakMap(); + this.customElementWatcher = new CustomElementWatcher( + targetActor.chromeEventHandler + ); + + // In this map, the key-value pairs are the overflow causing elements and their + // respective ancestor scrollable node actor. + this.overflowCausingElementsMap = new Map(); + + this.showAllAnonymousContent = options.showAllAnonymousContent; + + this.walkerSearch = new WalkerSearch(this); + + // Nodes which have been removed from the client's known + // ownership tree are considered "orphaned", and stored in + // this set. + this._orphaned = new Set(); + + // The client can tell the walker that it is interested in a node + // even when it is orphaned with the `retainNode` method. This + // list contains orphaned nodes that were so retained. + this._retainedOrphans = new Set(); + + this.onSubtreeModified = this.onSubtreeModified.bind(this); + this.onSubtreeModified[EXCLUDED_LISTENER] = true; + this.onNodeRemoved = this.onNodeRemoved.bind(this); + this.onNodeRemoved[EXCLUDED_LISTENER] = true; + this.onAttributeModified = this.onAttributeModified.bind(this); + this.onAttributeModified[EXCLUDED_LISTENER] = true; + + this.onMutations = this.onMutations.bind(this); + this.onSlotchange = this.onSlotchange.bind(this); + this.onShadowrootattached = this.onShadowrootattached.bind(this); + this.onAnonymousrootcreated = this.onAnonymousrootcreated.bind(this); + this.onAnonymousrootremoved = this.onAnonymousrootremoved.bind(this); + this.onFrameLoad = this.onFrameLoad.bind(this); + this.onFrameUnload = this.onFrameUnload.bind(this); + this.onCustomElementDefined = this.onCustomElementDefined.bind(this); + this._throttledEmitNewMutations = throttle( + this._emitNewMutations.bind(this), + MUTATIONS_THROTTLING_DELAY + ); + + targetActor.on("will-navigate", this.onFrameUnload); + targetActor.on("window-ready", this.onFrameLoad); + + this.customElementWatcher.on( + "element-defined", + this.onCustomElementDefined + ); + + // Keep a reference to the chromeEventHandler for the current targetActor, to make + // sure we will be able to remove the listener during the WalkerActor destroy(). + this.chromeEventHandler = targetActor.chromeEventHandler; + // shadowrootattached is a chrome-only event. We enable it below. + this.chromeEventHandler.addEventListener( + "shadowrootattached", + this.onShadowrootattached + ); + // anonymousrootcreated is a chrome-only event. We enable it below. + this.chromeEventHandler.addEventListener( + "anonymousrootcreated", + this.onAnonymousrootcreated + ); + this.chromeEventHandler.addEventListener( + "anonymousrootremoved", + this.onAnonymousrootremoved + ); + for (const { document } of this.targetActor.windows) { + document.devToolsAnonymousAndShadowEventsEnabled = true; + } + + // Ensure that the root document node actor is ready and + // managed. + this.rootNode = this.document(); + + this.layoutChangeObserver = getLayoutChangesObserver(this.targetActor); + this._onReflows = this._onReflows.bind(this); + this.layoutChangeObserver.on("reflows", this._onReflows); + this._onResize = this._onResize.bind(this); + this.layoutChangeObserver.on("resize", this._onResize); + + this._onEventListenerChange = this._onEventListenerChange.bind(this); + eventListenerService.addListenerChangeListener(this._onEventListenerChange); + } + + get nodePicker() { + if (!this._nodePicker) { + this._nodePicker = new NodePicker(this, this.targetActor); + } + + return this._nodePicker; + } + + watchRootNode() { + if (this.rootNode) { + this.emit("root-available", this.rootNode); + } + } + + /** + * Callback for eventListenerService.addListenerChangeListener + * @param nsISimpleEnumerator changesEnum + * enumerator of nsIEventListenerChange + */ + _onEventListenerChange(changesEnum) { + for (const current of changesEnum.enumerate(Ci.nsIEventListenerChange)) { + const target = current.target; + + if (this._nodeActorsMap.has(target)) { + const actor = this.getNode(target); + const mutation = { + type: "events", + target: actor.actorID, + hasEventListeners: actor._hasEventListeners, + }; + this.queueMutation(mutation); + } + } + } + + // Returns the JSON representation of this object over the wire. + form() { + return { + actor: this.actorID, + root: this.rootNode.form(), + traits: {}, + }; + } + + toString() { + return "[WalkerActor " + this.actorID + "]"; + } + + getDocumentWalker(node, skipTo) { + // Allow native anon content (like <video> controls) if preffed on + const filter = this.showAllAnonymousContent + ? allAnonymousContentTreeWalkerFilter + : standardTreeWalkerFilter; + + return new DocumentWalker(node, this.rootWin, { + filter, + skipTo, + showAnonymousContent: true, + }); + } + + destroy() { + if (this._destroyed) { + return; + } + this._destroyed = true; + super.destroy(); + try { + this.clearPseudoClassLocks(); + this._activePseudoClassLocks = null; + + this.overflowCausingElementsMap.clear(); + this.overflowCausingElementsMap = null; + + this._hoveredNode = null; + this.rootWin = null; + this.rootDoc = null; + this.rootNode = null; + this.layoutHelpers = null; + this._orphaned = null; + this._retainedOrphans = null; + this._nodeActorsMap = null; + + this.targetActor.off("will-navigate", this.onFrameUnload); + this.targetActor.off("window-ready", this.onFrameLoad); + this.customElementWatcher.off( + "element-defined", + this.onCustomElementDefined + ); + + this.chromeEventHandler.removeEventListener( + "shadowrootattached", + this.onShadowrootattached + ); + this.chromeEventHandler.removeEventListener( + "anonymousrootcreated", + this.onAnonymousrootcreated + ); + this.chromeEventHandler.removeEventListener( + "anonymousrootremoved", + this.onAnonymousrootremoved + ); + + // This attribute is just for devtools, so we can unset once we're done. + for (const { document } of this.targetActor.windows) { + document.devToolsAnonymousAndShadowEventsEnabled = false; + } + + this.onFrameLoad = null; + this.onFrameUnload = null; + + this.customElementWatcher.destroy(); + this.customElementWatcher = null; + + this.walkerSearch.destroy(); + + if (this._nodePicker) { + this._nodePicker.destroy(); + this._nodePicker = null; + } + + this.layoutChangeObserver.off("reflows", this._onReflows); + this.layoutChangeObserver.off("resize", this._onResize); + this.layoutChangeObserver = null; + releaseLayoutChangesObserver(this.targetActor); + + eventListenerService.removeListenerChangeListener( + this._onEventListenerChange + ); + + this.onMutations = null; + + this.layoutActor = null; + this.targetActor = null; + this.chromeEventHandler = null; + + this.emit("destroyed"); + } catch (e) { + console.error(e); + } + } + + release() {} + + unmanage(actor) { + if (actor instanceof NodeActor) { + if ( + this._activePseudoClassLocks && + this._activePseudoClassLocks.has(actor) + ) { + this.clearPseudoClassLocks(actor); + } + + this.customElementWatcher.unmanageNode(actor); + + this._nodeActorsMap.delete(actor.rawNode); + } + super.unmanage(actor); + } + + /** + * Determine if the walker has come across this DOM node before. + * @param {DOMNode} rawNode + * @return {Boolean} + */ + hasNode(rawNode) { + return this._nodeActorsMap.has(rawNode); + } + + /** + * If the walker has come across this DOM node before, then get the + * corresponding node actor. + * @param {DOMNode} rawNode + * @return {NodeActor} + */ + getNode(rawNode) { + return this._nodeActorsMap.get(rawNode); + } + + /** + * Internal helper that will either retrieve the existing NodeActor for the + * provided node or create the actor on the fly if it doesn't exist. + * This method should only be called when we are sure that the node should be + * known by the client and that the parent node is already known. + * + * Otherwise prefer `getNode` to only retrieve known actors or `attachElement` + * to create node actors recursively. + * + * @param {DOMNode} node + * The node for which we want to create or get an actor + * @return {NodeActor} The corresponding NodeActor + */ + _getOrCreateNodeActor(node) { + let actor = this.getNode(node); + if (actor) { + return actor; + } + + actor = new NodeActor(this, node); + + // Add the node actor as a child of this walker actor, assigning + // it an actorID. + this.manage(actor); + this._nodeActorsMap.set(node, actor); + + if (node.nodeType === Node.DOCUMENT_NODE) { + actor.watchDocument(node, this.onMutations); + } + + if (isShadowRoot(actor.rawNode)) { + actor.watchDocument(node.ownerDocument, this.onMutations); + actor.watchSlotchange(this.onSlotchange); + } + + this.customElementWatcher.manageNode(actor); + + return actor; + } + + /** + * When a custom element is defined, send a customElementDefined mutation for all the + * NodeActors using this tag name. + */ + onCustomElementDefined({ name, actors }) { + actors.forEach(actor => + this.queueMutation({ + target: actor.actorID, + type: "customElementDefined", + customElementLocation: actor.getCustomElementLocation(), + }) + ); + } + + _onReflows(reflows) { + // Going through the nodes the walker knows about, see which ones have had their + // display, scrollable or overflow state changed and send events if any. + const displayTypeChanges = []; + const scrollableStateChanges = []; + + const currentOverflowCausingElementsMap = new Map(); + + for (const [node, actor] of this._nodeActorsMap) { + if (Cu.isDeadWrapper(node)) { + continue; + } + + const displayType = actor.displayType; + const isDisplayed = actor.isDisplayed; + + if ( + displayType !== actor.currentDisplayType || + isDisplayed !== actor.wasDisplayed + ) { + displayTypeChanges.push(actor); + + // Updating the original value + actor.currentDisplayType = displayType; + actor.wasDisplayed = isDisplayed; + } + + const isScrollable = actor.isScrollable; + if (isScrollable !== actor.wasScrollable) { + scrollableStateChanges.push(actor); + actor.wasScrollable = isScrollable; + } + + if (isScrollable) { + this.updateOverflowCausingElements( + actor, + currentOverflowCausingElementsMap + ); + } + } + + // Get the NodeActor for each node in the symmetric difference of + // currentOverflowCausingElementsMap and this.overflowCausingElementsMap + const overflowStateChanges = [...currentOverflowCausingElementsMap.keys()] + .filter(node => !this.overflowCausingElementsMap.has(node)) + .concat( + [...this.overflowCausingElementsMap.keys()].filter( + node => !currentOverflowCausingElementsMap.has(node) + ) + ) + .filter(node => this.hasNode(node)) + .map(node => this.getNode(node)); + + this.overflowCausingElementsMap = currentOverflowCausingElementsMap; + + if (overflowStateChanges.length) { + this.emit("overflow-change", overflowStateChanges); + } + + if (displayTypeChanges.length) { + this.emit("display-change", displayTypeChanges); + } + + if (scrollableStateChanges.length) { + this.emit("scrollable-change", scrollableStateChanges); + } + } + + /** + * When the browser window gets resized, relay the event to the front. + */ + _onResize() { + this.emit("resize"); + } + + /** + * Ensures that the node is attached and it can be accessed from the root. + * + * @param {(Node|NodeActor)} nodes The nodes + * @return {Object} An object compatible with the disconnectedNode type. + */ + attachElement(node) { + const { nodes, newParents } = this.attachElements([node]); + return { + node: nodes[0], + newParents, + }; + } + + /** + * Ensures that the nodes are attached and they can be accessed from the root. + * + * @param {(Node[]|NodeActor[])} nodes The nodes + * @return {Object} An object compatible with the disconnectedNodeArray type. + */ + attachElements(nodes) { + const nodeActors = []; + const newParents = new Set(); + for (let node of nodes) { + if (!(node instanceof NodeActor)) { + // If an anonymous node was passed in and we aren't supposed to know + // about it, then use the closest ancestor. + if (!this.showAllAnonymousContent) { + while ( + node && + standardTreeWalkerFilter(node) != nodeFilterConstants.FILTER_ACCEPT + ) { + node = this.rawParentNode(node); + } + if (!node) { + continue; + } + } + + node = this._getOrCreateNodeActor(node); + } + + this.ensurePathToRoot(node, newParents); + // If nodes may be an array of raw nodes, we're sure to only have + // NodeActors with the following array. + nodeActors.push(node); + } + + return { + nodes: nodeActors, + newParents: [...newParents], + }; + } + + /** + * Return the document node that contains the given node, + * or the root node if no node is specified. + * @param NodeActor node + * The node whose document is needed, or null to + * return the root. + */ + document(node) { + const doc = isNodeDead(node) ? this.rootDoc : nodeDocument(node.rawNode); + return this._getOrCreateNodeActor(doc); + } + + /** + * Return the documentElement for the document containing the + * given node. + * @param NodeActor node + * The node whose documentElement is requested, or null + * to use the root document. + */ + documentElement(node) { + const elt = isNodeDead(node) + ? this.rootDoc.documentElement + : nodeDocument(node.rawNode).documentElement; + return this._getOrCreateNodeActor(elt); + } + + parentNode(node) { + const parent = this.rawParentNode(node); + if (parent) { + return this._getOrCreateNodeActor(parent); + } + + return null; + } + + rawParentNode(node) { + const rawNode = node instanceof NodeActor ? node.rawNode : node; + if (rawNode == this.rootDoc) { + return null; + } + return InspectorUtils.getParentForNode(rawNode, /* anonymous = */ true); + } + + /** + * If the given NodeActor only has a single text node as a child with a text + * content small enough to be inlined, return that child's NodeActor. + * + * @param NodeActor node + */ + inlineTextChild({ rawNode }) { + // Quick checks to prevent creating a new walker if possible. + if ( + isMarkerPseudoElement(rawNode) || + isBeforePseudoElement(rawNode) || + isAfterPseudoElement(rawNode) || + isShadowHost(rawNode) || + rawNode.nodeType != Node.ELEMENT_NODE || + !!rawNode.children.length || + isFrameWithChildTarget(this.targetActor, rawNode) || + isFrameBlockedByCSP(rawNode) + ) { + return undefined; + } + + const children = this._rawChildren(rawNode, /* includeAssigned = */ true); + const firstChild = children[0]; + + // Bail out if: + // - more than one child + // - unique child is not a text node + // - unique child is a text node, but is too long to be inlined + // - we are a slot -> these are always represented on their own lines with + // a link to the original node. + // - we are a flex item -> these are always shown on their own lines so they can be + // selected by the flexbox inspector. + const isAssignedToSlot = + firstChild && + rawNode.nodeName === "SLOT" && + isDirectShadowHostChild(firstChild); + + const isFlexItem = !!firstChild?.parentFlexElement; + + if ( + !firstChild || + children.length > 1 || + firstChild.nodeType !== Node.TEXT_NODE || + firstChild.nodeValue.length > gValueSummaryLength || + isAssignedToSlot || + isFlexItem + ) { + return undefined; + } + + return this._getOrCreateNodeActor(firstChild); + } + + /** + * Mark a node as 'retained'. + * + * A retained node is not released when `releaseNode` is called on its + * parent, or when a parent is released with the `cleanup` option to + * `getMutations`. + * + * When a retained node's parent is released, a retained mode is added to + * the walker's "retained orphans" list. + * + * Retained nodes can be deleted by providing the `force` option to + * `releaseNode`. They will also be released when their document + * has been destroyed. + * + * Retaining a node makes no promise about its children; They can + * still be removed by normal means. + */ + retainNode(node) { + node.retained = true; + } + + /** + * Remove the 'retained' mark from a node. If the node was a + * retained orphan, release it. + */ + unretainNode(node) { + node.retained = false; + if (this._retainedOrphans.has(node)) { + this._retainedOrphans.delete(node); + this.releaseNode(node); + } + } + + /** + * Release actors for a node and all child nodes. + */ + releaseNode(node, options = {}) { + if (isNodeDead(node)) { + return; + } + + if (node.retained && !options.force) { + this._retainedOrphans.add(node); + return; + } + + if (node.retained) { + // Forcing a retained node to go away. + this._retainedOrphans.delete(node); + } + + for (const child of this._rawChildren(node.rawNode)) { + const childActor = this.getNode(child); + if (childActor) { + this.releaseNode(childActor, options); + } + } + + node.destroy(); + } + + /** + * Add any nodes between `node` and the walker's root node that have not + * yet been seen by the client. + */ + ensurePathToRoot(node, newParents = new Set()) { + if (!node) { + return newParents; + } + let parent = this.rawParentNode(node); + while (parent) { + let parentActor = this.getNode(parent); + if (parentActor) { + // This parent did exist, so the client knows about it. + return newParents; + } + // This parent didn't exist, so hasn't been seen by the client yet. + parentActor = this._getOrCreateNodeActor(parent); + newParents.add(parentActor); + parent = this.rawParentNode(parentActor); + } + return newParents; + } + + /** + * Return the number of children under the provided NodeActor. + * + * @param NodeActor node + * See JSDoc for children() + * @param object options + * See JSDoc for children() + * @return Number the number of children + */ + countChildren(node, options = {}) { + return this._getChildren(node, options).nodes.length; + } + + /** + * Return children of the given node. By default this method will return + * all children of the node, but there are options that can restrict this + * to a more manageable subset. + * + * @param NodeActor node + * The node whose children you're curious about. + * @param object options + * Named options: + * `maxNodes`: The set of nodes returned by the method will be no longer + * than maxNodes. + * `start`: If a node is specified, the list of nodes will start + * with the given child. Mutally exclusive with `center`. + * `center`: If a node is specified, the given node will be as centered + * as possible in the list, given how close to the ends of the child + * list it is. Mutually exclusive with `start`. + * + * @returns an object with three items: + * hasFirst: true if the first child of the node is included in the list. + * hasLast: true if the last child of the node is included in the list. + * nodes: Array of NodeActor representing the nodes returned by the request. + */ + children(node, options = {}) { + const { hasFirst, hasLast, nodes } = this._getChildren(node, options); + return { + hasFirst, + hasLast, + nodes: nodes.map(n => this._getOrCreateNodeActor(n)), + }; + } + + /** + * Returns the raw children of the DOM node, with anon content filtered as needed + * @param Node rawNode. + * @param boolean includeAssigned + * Whether <slot> assigned children should be returned. See + * HTMLSlotElement.assignedNodes(). + * @returns Array<Node> the list of children. + */ + _rawChildren(rawNode, includeAssigned) { + const filter = this.showAllAnonymousContent + ? allAnonymousContentTreeWalkerFilter + : standardTreeWalkerFilter; + const ret = []; + const children = InspectorUtils.getChildrenForNode( + rawNode, + /* anonymous = */ true, + includeAssigned + ); + for (const child of children) { + if (filter(child) == nodeFilterConstants.FILTER_ACCEPT) { + ret.push(child); + } + } + return ret; + } + + /** + * Return chidlren of the given node. Contrary to children children(), this method only + * returns DOMNodes. Therefore it will not create NodeActor wrappers and will not + * update the nodeActors map for the discovered nodes either. This makes this method + * safe to call when you are not sure if the discovered nodes will be communicated to + * the client. + * + * @param NodeActor node + * See JSDoc for children() + * @param object options + * See JSDoc for children() + * @return an object with three items: + * hasFirst: true if the first child of the node is included in the list. + * hasLast: true if the last child of the node is included in the list. + * nodes: Array of DOMNodes. + */ + // eslint-disable-next-line complexity + _getChildren(node, options = {}) { + if (isNodeDead(node) || isFrameBlockedByCSP(node.rawNode)) { + return { hasFirst: true, hasLast: true, nodes: [] }; + } + + if (options.center && options.start) { + throw Error("Can't specify both 'center' and 'start' options."); + } + + let maxNodes = options.maxNodes || -1; + if (maxNodes == -1) { + maxNodes = Number.MAX_VALUE; + } + + let nodes = this._rawChildren(node.rawNode, /* includeAssigned = */ true); + let hasFirst = true; + let hasLast = true; + if (nodes.length > maxNodes) { + let startIndex; + if (options.center) { + const centerIndex = nodes.indexOf(options.center.rawNode); + const backwardCount = Math.floor(maxNodes / 2); + // If centering would hit the end, just read the last maxNodes nodes. + if (centerIndex - backwardCount + maxNodes >= nodes.length) { + startIndex = nodes.length - maxNodes; + } else { + startIndex = Math.max(0, centerIndex - backwardCount); + } + } else if (options.start) { + startIndex = Math.max(0, nodes.indexOf(options.start.rawNode)); + } else { + startIndex = 0; + } + const endIndex = Math.min(startIndex + maxNodes, nodes.length); + hasFirst = startIndex == 0; + hasLast = endIndex >= nodes.length; + nodes = nodes.slice(startIndex, endIndex); + } + + return { hasFirst, hasLast, nodes }; + } + + /** + * Get the next sibling of a given node. Getting nodes one at a time + * might be inefficient, be careful. + */ + nextSibling(node) { + if (isNodeDead(node)) { + return null; + } + + const walker = this.getDocumentWalker(node.rawNode); + const sibling = walker.nextSibling(); + return sibling ? this._getOrCreateNodeActor(sibling) : null; + } + + /** + * Get the previous sibling of a given node. Getting nodes one at a time + * might be inefficient, be careful. + */ + previousSibling(node) { + if (isNodeDead(node)) { + return null; + } + + const walker = this.getDocumentWalker(node.rawNode); + const sibling = walker.previousSibling(); + return sibling ? this._getOrCreateNodeActor(sibling) : null; + } + + /** + * Helper function for the `children` method: Read forward in the sibling + * list into an array with `count` items, including the current node. + */ + _readForward(walker, count) { + const ret = []; + + let node = walker.currentNode; + do { + if (!walker.isSkippedNode(node)) { + // The walker can be on a node that would be filtered out if it didn't find any + // other node to fallback to. + ret.push(node); + } + node = walker.nextSibling(); + } while (node && --count); + return ret; + } + + /** + * Return the first node in the document that matches the given selector. + * See https://developer.mozilla.org/en-US/docs/Web/API/Element.querySelector + * + * @param NodeActor baseNode + * @param string selector + */ + querySelector(baseNode, selector) { + if (isNodeDead(baseNode)) { + return {}; + } + + const node = baseNode.rawNode.querySelector(selector); + if (!node) { + return {}; + } + + return this.attachElement(node); + } + + /** + * Return a NodeListActor with all nodes that match the given selector. + * See https://developer.mozilla.org/en-US/docs/Web/API/Element.querySelectorAll + * + * @param NodeActor baseNode + * @param string selector + */ + querySelectorAll(baseNode, selector) { + let nodeList = null; + + try { + nodeList = baseNode.rawNode.querySelectorAll(selector); + } catch (e) { + // Bad selector. Do nothing as the selector can come from a searchbox. + } + + return new NodeListActor(this, nodeList); + } + + /** + * Get a list of nodes that match the given selector in all known frames of + * the current content page. + * @param {String} selector. + * @return {Array} + */ + _multiFrameQuerySelectorAll(selector) { + let nodes = []; + + for (const { document } of this.targetActor.windows) { + try { + nodes = [...nodes, ...document.querySelectorAll(selector)]; + } catch (e) { + // Bad selector. Do nothing as the selector can come from a searchbox. + } + } + + return nodes; + } + + /** + * Get a list of nodes that match the given XPath in all known frames of + * the current content page. + * @param {String} xPath. + * @return {Array} + */ + _multiFrameXPath(xPath) { + const nodes = []; + + for (const window of this.targetActor.windows) { + const document = window.document; + try { + const result = document.evaluate( + xPath, + document.documentElement, + null, + window.XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null + ); + + for (let i = 0; i < result.snapshotLength; i++) { + nodes.push(result.snapshotItem(i)); + } + } catch (e) { + // Bad XPath. Do nothing as the XPath can come from a searchbox. + } + } + + return nodes; + } + + /** + * Return a NodeListActor with all nodes that match the given XPath in all + * frames of the current content page. + * @param {String} xPath + */ + multiFrameXPath(xPath) { + return new NodeListActor(this, this._multiFrameXPath(xPath)); + } + + /** + * Search the document for a given string. + * Results will be searched with the walker-search module (searches through + * tag names, attribute names and values, and text contents). + * + * @returns {searchresult} + * - {NodeList} list + * - {Array<Object>} metadata. Extra information with indices that + * match up with node list. + */ + search(query) { + const results = this.walkerSearch.search(query); + const nodeList = new NodeListActor( + this, + results.map(r => r.node) + ); + + return { + list: nodeList, + metadata: [], + }; + } + + /** + * Returns a list of matching results for CSS selector autocompletion. + * + * @param string query + * The selector query being completed + * @param string completing + * The exact token being completed out of the query + * @param string selectorState + * One of "pseudo", "id", "tag", "class", "null" + */ + // eslint-disable-next-line complexity + getSuggestionsForQuery(query, completing, selectorState) { + const sugs = { + classes: new Map(), + tags: new Map(), + ids: new Map(), + }; + let result = []; + let nodes = null; + // Filtering and sorting the results so that protocol transfer is miminal. + switch (selectorState) { + case "pseudo": + result = PSEUDO_SELECTORS.filter(item => { + return item[0].startsWith(":" + completing); + }); + break; + + case "class": + if (!query) { + nodes = this._multiFrameQuerySelectorAll("[class]"); + } else { + nodes = this._multiFrameQuerySelectorAll(query); + } + for (const node of nodes) { + for (const className of node.classList) { + sugs.classes.set(className, (sugs.classes.get(className) | 0) + 1); + } + } + sugs.classes.delete(""); + sugs.classes.delete(HIDDEN_CLASS); + for (const [className, count] of sugs.classes) { + if (className.startsWith(completing)) { + result.push(["." + CSS.escape(className), count, selectorState]); + } + } + break; + + case "id": + if (!query) { + nodes = this._multiFrameQuerySelectorAll("[id]"); + } else { + nodes = this._multiFrameQuerySelectorAll(query); + } + for (const node of nodes) { + sugs.ids.set(node.id, (sugs.ids.get(node.id) | 0) + 1); + } + for (const [id, count] of sugs.ids) { + if (id.startsWith(completing) && id !== "") { + result.push(["#" + CSS.escape(id), count, selectorState]); + } + } + break; + + case "tag": + if (!query) { + nodes = this._multiFrameQuerySelectorAll("*"); + } else { + nodes = this._multiFrameQuerySelectorAll(query); + } + for (const node of nodes) { + const tag = node.localName; + sugs.tags.set(tag, (sugs.tags.get(tag) | 0) + 1); + } + for (const [tag, count] of sugs.tags) { + if (new RegExp("^" + completing + ".*", "i").test(tag)) { + result.push([tag, count, selectorState]); + } + } + + // For state 'tag' (no preceding # or .) and when there's no query (i.e. + // only one word) then search for the matching classes and ids + if (!query) { + result = [ + ...result, + ...this.getSuggestionsForQuery(null, completing, "class") + .suggestions, + ...this.getSuggestionsForQuery(null, completing, "id").suggestions, + ]; + } + + break; + + case "null": + nodes = this._multiFrameQuerySelectorAll(query); + for (const node of nodes) { + sugs.ids.set(node.id, (sugs.ids.get(node.id) | 0) + 1); + const tag = node.localName; + sugs.tags.set(tag, (sugs.tags.get(tag) | 0) + 1); + for (const className of node.classList) { + sugs.classes.set(className, (sugs.classes.get(className) | 0) + 1); + } + } + for (const [tag, count] of sugs.tags) { + tag && result.push([tag, count]); + } + for (const [id, count] of sugs.ids) { + id && result.push(["#" + id, count]); + } + sugs.classes.delete(""); + sugs.classes.delete(HIDDEN_CLASS); + for (const [className, count] of sugs.classes) { + className && result.push(["." + className, count]); + } + } + + // Sort by count (desc) and name (asc) + result = result.sort((a, b) => { + // Computed a sortable string with first the inverted count, then the name + let sortA = 10000 - a[1] + a[0]; + let sortB = 10000 - b[1] + b[0]; + + // Prefixing ids, classes and tags, to group results + const firstA = a[0].substring(0, 1); + const firstB = b[0].substring(0, 1); + + const getSortKeyPrefix = firstLetter => { + if (firstLetter === "#") { + return "2"; + } + if (firstLetter === ".") { + return "1"; + } + return "0"; + }; + + sortA = getSortKeyPrefix(firstA) + sortA; + sortB = getSortKeyPrefix(firstB) + sortB; + + // String compare + return sortA.localeCompare(sortB); + }); + + result = result.slice(0, 25); + + return { + query, + suggestions: result, + }; + } + + /** + * Add a pseudo-class lock to a node. + * + * @param NodeActor node + * @param string pseudo + * A pseudoclass: ':hover', ':active', ':focus', ':focus-within' + * @param options + * Options object: + * `parents`: True if the pseudo-class should be added + * to parent nodes. + * `enabled`: False if the pseudo-class should be locked + * to 'off'. Defaults to true. + * + * @returns An empty packet. A "pseudoClassLock" mutation will + * be queued for any changed nodes. + */ + addPseudoClassLock(node, pseudo, options = {}) { + if (isNodeDead(node)) { + return; + } + + // There can be only one node locked per pseudo, so dismiss all existing + // ones + for (const locked of this._activePseudoClassLocks) { + if (InspectorUtils.hasPseudoClassLock(locked.rawNode, pseudo)) { + this._removePseudoClassLock(locked, pseudo); + } + } + + const enabled = options.enabled === undefined || options.enabled; + this._addPseudoClassLock(node, pseudo, enabled); + + if (!options.parents) { + return; + } + + const walker = this.getDocumentWalker(node.rawNode); + let cur; + while ((cur = walker.parentNode())) { + const curNode = this._getOrCreateNodeActor(cur); + this._addPseudoClassLock(curNode, pseudo, enabled); + } + } + + _queuePseudoClassMutation(node) { + this.queueMutation({ + target: node.actorID, + type: "pseudoClassLock", + pseudoClassLocks: node.writePseudoClassLocks(), + }); + } + + _addPseudoClassLock(node, pseudo, enabled) { + if (node.rawNode.nodeType !== Node.ELEMENT_NODE) { + return false; + } + InspectorUtils.addPseudoClassLock(node.rawNode, pseudo, enabled); + this._activePseudoClassLocks.add(node); + this._queuePseudoClassMutation(node); + return true; + } + + hideNode(node) { + if (isNodeDead(node)) { + return; + } + + loadSheet(node.rawNode.ownerGlobal, HELPER_SHEET); + node.rawNode.classList.add(HIDDEN_CLASS); + } + + unhideNode(node) { + if (isNodeDead(node)) { + return; + } + + node.rawNode.classList.remove(HIDDEN_CLASS); + } + + /** + * Remove a pseudo-class lock from a node. + * + * @param NodeActor node + * @param string pseudo + * A pseudoclass: ':hover', ':active', ':focus', ':focus-within' + * @param options + * Options object: + * `parents`: True if the pseudo-class should be removed + * from parent nodes. + * + * @returns An empty response. "pseudoClassLock" mutations + * will be emitted for any changed nodes. + */ + removePseudoClassLock(node, pseudo, options = {}) { + if (isNodeDead(node)) { + return; + } + + this._removePseudoClassLock(node, pseudo); + + // Remove pseudo class for children as we don't want to allow + // turning it on for some childs without setting it on some parents + for (const locked of this._activePseudoClassLocks) { + if ( + node.rawNode.contains(locked.rawNode) && + InspectorUtils.hasPseudoClassLock(locked.rawNode, pseudo) + ) { + this._removePseudoClassLock(locked, pseudo); + } + } + + if (!options.parents) { + return; + } + + const walker = this.getDocumentWalker(node.rawNode); + let cur; + while ((cur = walker.parentNode())) { + const curNode = this._getOrCreateNodeActor(cur); + this._removePseudoClassLock(curNode, pseudo); + } + } + + _removePseudoClassLock(node, pseudo) { + if (node.rawNode.nodeType != Node.ELEMENT_NODE) { + return false; + } + InspectorUtils.removePseudoClassLock(node.rawNode, pseudo); + if (!node.writePseudoClassLocks()) { + this._activePseudoClassLocks.delete(node); + } + + this._queuePseudoClassMutation(node); + return true; + } + + /** + * Clear all the pseudo-classes on a given node or all nodes. + * @param {NodeActor} node Optional node to clear pseudo-classes on + */ + clearPseudoClassLocks(node) { + if (node && isNodeDead(node)) { + return; + } + + if (node) { + InspectorUtils.clearPseudoClassLocks(node.rawNode); + this._activePseudoClassLocks.delete(node); + this._queuePseudoClassMutation(node); + } else { + for (const locked of this._activePseudoClassLocks) { + InspectorUtils.clearPseudoClassLocks(locked.rawNode); + this._activePseudoClassLocks.delete(locked); + this._queuePseudoClassMutation(locked); + } + } + } + + /** + * Get a node's innerHTML property. + */ + innerHTML(node) { + let html = ""; + if (!isNodeDead(node)) { + html = node.rawNode.innerHTML; + } + return new LongStringActor(this.conn, html); + } + + /** + * Set a node's innerHTML property. + * + * @param {NodeActor} node The node. + * @param {string} value The piece of HTML content. + */ + setInnerHTML(node, value) { + if (isNodeDead(node)) { + return; + } + + const rawNode = node.rawNode; + if ( + rawNode.nodeType !== rawNode.ownerDocument.ELEMENT_NODE && + rawNode.nodeType !== rawNode.ownerDocument.DOCUMENT_FRAGMENT_NODE + ) { + throw new Error("Can only change innerHTML to element or fragment nodes"); + } + // eslint-disable-next-line no-unsanitized/property + rawNode.innerHTML = value; + } + + /** + * Get a node's outerHTML property. + * + * @param {NodeActor} node The node. + */ + outerHTML(node) { + let outerHTML = ""; + if (!isNodeDead(node)) { + outerHTML = node.rawNode.outerHTML; + } + return new LongStringActor(this.conn, outerHTML); + } + + /** + * Set a node's outerHTML property. + * + * @param {NodeActor} node The node. + * @param {string} value The piece of HTML content. + */ + setOuterHTML(node, value) { + if (isNodeDead(node)) { + return; + } + + const rawNode = node.rawNode; + const doc = nodeDocument(rawNode); + const win = doc.defaultView; + let parser; + if (!win) { + throw new Error("The window object shouldn't be null"); + } else { + // We create DOMParser under window object because we want a content + // DOMParser, which means all the DOM objects created by this DOMParser + // will be in the same DocGroup as rawNode.parentNode. Then the newly + // created nodes can be adopted into rawNode.parentNode. + parser = new win.DOMParser(); + } + + const mimeType = rawNode.tagName === "svg" ? "image/svg+xml" : "text/html"; + const parsedDOM = parser.parseFromString(value, mimeType); + const parentNode = rawNode.parentNode; + + // Special case for head and body. Setting document.body.outerHTML + // creates an extra <head> tag, and document.head.outerHTML creates + // an extra <body>. So instead we will call replaceChild with the + // parsed DOM, assuming that they aren't trying to set both tags at once. + if (rawNode.tagName === "BODY") { + if (parsedDOM.head.innerHTML === "") { + parentNode.replaceChild(parsedDOM.body, rawNode); + } else { + // eslint-disable-next-line no-unsanitized/property + rawNode.outerHTML = value; + } + } else if (rawNode.tagName === "HEAD") { + if (parsedDOM.body.innerHTML === "") { + parentNode.replaceChild(parsedDOM.head, rawNode); + } else { + // eslint-disable-next-line no-unsanitized/property + rawNode.outerHTML = value; + } + } else if (node.isDocumentElement()) { + // Unable to set outerHTML on the document element. Fall back by + // setting attributes manually. Then replace all the child nodes. + const finalAttributeModifications = []; + const attributeModifications = {}; + for (const attribute of rawNode.attributes) { + attributeModifications[attribute.name] = null; + } + for (const attribute of parsedDOM.documentElement.attributes) { + attributeModifications[attribute.name] = attribute.value; + } + for (const key in attributeModifications) { + finalAttributeModifications.push({ + attributeName: key, + newValue: attributeModifications[key], + }); + } + node.modifyAttributes(finalAttributeModifications); + + rawNode.replaceChildren(...parsedDOM.firstElementChild.childNodes); + } else { + // eslint-disable-next-line no-unsanitized/property + rawNode.outerHTML = value; + } + } + + /** + * Insert adjacent HTML to a node. + * + * @param {Node} node + * @param {string} position One of "beforeBegin", "afterBegin", "beforeEnd", + * "afterEnd" (see Element.insertAdjacentHTML). + * @param {string} value The HTML content. + */ + insertAdjacentHTML(node, position, value) { + if (isNodeDead(node)) { + return { node: [], newParents: [] }; + } + + const rawNode = node.rawNode; + const isInsertAsSibling = + position === "beforeBegin" || position === "afterEnd"; + + // Don't insert anything adjacent to the document element. + if (isInsertAsSibling && node.isDocumentElement()) { + throw new Error("Can't insert adjacent element to the root."); + } + + const rawParentNode = rawNode.parentNode; + if (!rawParentNode && isInsertAsSibling) { + throw new Error("Can't insert as sibling without parent node."); + } + + // We can't use insertAdjacentHTML, because we want to return the nodes + // being created (so the front can remove them if the user undoes + // the change). So instead, use Range.createContextualFragment(). + const range = rawNode.ownerDocument.createRange(); + if (position === "beforeBegin" || position === "afterEnd") { + range.selectNode(rawNode); + } else { + range.selectNodeContents(rawNode); + } + // eslint-disable-next-line no-unsanitized/method + const docFrag = range.createContextualFragment(value); + const newRawNodes = Array.from(docFrag.childNodes); + switch (position) { + case "beforeBegin": + rawParentNode.insertBefore(docFrag, rawNode); + break; + case "afterEnd": + // Note: if the second argument is null, rawParentNode.insertBefore + // behaves like rawParentNode.appendChild. + rawParentNode.insertBefore(docFrag, rawNode.nextSibling); + break; + case "afterBegin": + rawNode.insertBefore(docFrag, rawNode.firstChild); + break; + case "beforeEnd": + rawNode.appendChild(docFrag); + break; + default: + throw new Error( + "Invalid position value. Must be either " + + "'beforeBegin', 'beforeEnd', 'afterBegin' or 'afterEnd'." + ); + } + + return this.attachElements(newRawNodes); + } + + /** + * Duplicate a specified node + * + * @param {NodeActor} node The node to duplicate. + */ + duplicateNode({ rawNode }) { + const clonedNode = rawNode.cloneNode(true); + rawNode.parentNode.insertBefore(clonedNode, rawNode.nextSibling); + } + + /** + * Test whether a node is a document or a document element. + * + * @param {NodeActor} node The node to remove. + * @return {boolean} True if the node is a document or a document element. + */ + isDocumentOrDocumentElementNode(node) { + return ( + (node.rawNode.ownerDocument && + node.rawNode.ownerDocument.documentElement === this.rawNode) || + node.rawNode.nodeType === Node.DOCUMENT_NODE + ); + } + + /** + * Removes a node from its parent node. + * + * @param {NodeActor} node The node to remove. + * @returns The node's nextSibling before it was removed. + */ + removeNode(node) { + if (isNodeDead(node) || this.isDocumentOrDocumentElementNode(node)) { + throw Error("Cannot remove document, document elements or dead nodes."); + } + + const nextSibling = this.nextSibling(node); + node.rawNode.remove(); + // Mutation events will take care of the rest. + return nextSibling; + } + + /** + * Removes an array of nodes from their parent node. + * + * @param {NodeActor[]} nodes The nodes to remove. + */ + removeNodes(nodes) { + // Check that all nodes are valid before processing the removals. + for (const node of nodes) { + if (isNodeDead(node) || this.isDocumentOrDocumentElementNode(node)) { + throw Error("Cannot remove document, document elements or dead nodes"); + } + } + + for (const node of nodes) { + node.rawNode.remove(); + // Mutation events will take care of the rest. + } + } + + /** + * Insert a node into the DOM. + */ + insertBefore(node, parent, sibling) { + if ( + isNodeDead(node) || + isNodeDead(parent) || + (sibling && isNodeDead(sibling)) + ) { + return; + } + + const rawNode = node.rawNode; + const rawParent = parent.rawNode; + const rawSibling = sibling ? sibling.rawNode : null; + + // Don't bother inserting a node if the document position isn't going + // to change. This prevents needless iframes reloading and mutations. + if (rawNode.parentNode === rawParent) { + let currentNextSibling = this.nextSibling(node); + currentNextSibling = currentNextSibling + ? currentNextSibling.rawNode + : null; + + if (rawNode === rawSibling || currentNextSibling === rawSibling) { + return; + } + } + + rawParent.insertBefore(rawNode, rawSibling); + } + + /** + * Editing a node's tagname actually means creating a new node with the same + * attributes, removing the node and inserting the new one instead. + * This method does not return anything as mutation events are taking care of + * informing the consumers about changes. + */ + editTagName(node, tagName) { + if (isNodeDead(node)) { + return null; + } + + const oldNode = node.rawNode; + + // Create a new element with the same attributes as the current element and + // prepare to replace the current node with it. + let newNode; + try { + newNode = nodeDocument(oldNode).createElement(tagName); + } catch (x) { + // Failed to create a new element with that tag name, ignore the change, + // and signal the error to the front. + return Promise.reject( + new Error("Could not change node's tagName to " + tagName) + ); + } + + const attrs = oldNode.attributes; + for (let i = 0; i < attrs.length; i++) { + newNode.setAttribute(attrs[i].name, attrs[i].value); + } + + // Insert the new node, and transfer the old node's children. + oldNode.parentNode.insertBefore(newNode, oldNode); + while (oldNode.firstChild) { + newNode.appendChild(oldNode.firstChild); + } + + oldNode.remove(); + return null; + } + + /** + * Gets the state of the mutation breakpoint types for this actor. + * + * @param {NodeActor} node The node to get breakpoint info for. + */ + getMutationBreakpoints(node) { + let bps; + if (!isNodeDead(node)) { + bps = this._breakpointInfoForNode(node.rawNode); + } + + return ( + bps || { + subtree: false, + removal: false, + attribute: false, + } + ); + } + + /** + * Set the state of some subset of mutation breakpoint types for this actor. + * + * @param {NodeActor} node The node to set breakpoint info for. + * @param {Object} bps A subset of the breakpoints for this actor that + * should be updated to new states. + */ + setMutationBreakpoints(node, bps) { + if (isNodeDead(node)) { + return; + } + const rawNode = node.rawNode; + + if ( + rawNode.ownerDocument && + rawNode.getRootNode({ composed: true }) != rawNode.ownerDocument + ) { + // We only allow watching for mutations on nodes that are attached to + // documents. That allows us to clean up our mutation listeners when all + // of the watched nodes have been removed from the document. + return; + } + + // This argument has nullable fields so we want to only update boolean + // field values. + const bpsForNode = Object.keys(bps).reduce((obj, bp) => { + if (typeof bps[bp] === "boolean") { + obj[bp] = bps[bp]; + } + return obj; + }, {}); + + this._updateMutationBreakpointState("api", rawNode, { + ...this.getMutationBreakpoints(node), + ...bpsForNode, + }); + } + + /** + * Update the mutation breakpoint state for the given DOM node. + * + * @param {Node} rawNode The DOM node. + * @param {Object} bpsForNode The state of each mutation bp type we support. + */ + _updateMutationBreakpointState(mutationReason, rawNode, bpsForNode) { + const rawDoc = rawNode.ownerDocument || rawNode; + + const docMutationBreakpoints = this._mutationBreakpointsForDoc( + rawDoc, + true /* createIfNeeded */ + ); + let originalBpsForNode = this._breakpointInfoForNode(rawNode); + + if (!bpsForNode && !originalBpsForNode) { + return; + } + + bpsForNode = bpsForNode || {}; + originalBpsForNode = originalBpsForNode || {}; + + if (Object.values(bpsForNode).some(Boolean)) { + docMutationBreakpoints.nodes.set(rawNode, bpsForNode); + } else { + docMutationBreakpoints.nodes.delete(rawNode); + } + if (originalBpsForNode.subtree && !bpsForNode.subtree) { + docMutationBreakpoints.counts.subtree -= 1; + } else if (!originalBpsForNode.subtree && bpsForNode.subtree) { + docMutationBreakpoints.counts.subtree += 1; + } + + if (originalBpsForNode.removal && !bpsForNode.removal) { + docMutationBreakpoints.counts.removal -= 1; + } else if (!originalBpsForNode.removal && bpsForNode.removal) { + docMutationBreakpoints.counts.removal += 1; + } + + if (originalBpsForNode.attribute && !bpsForNode.attribute) { + docMutationBreakpoints.counts.attribute -= 1; + } else if (!originalBpsForNode.attribute && bpsForNode.attribute) { + docMutationBreakpoints.counts.attribute += 1; + } + + this._updateDocumentMutationListeners(rawDoc); + + const actor = this.getNode(rawNode); + if (actor) { + this.queueMutation({ + target: actor.actorID, + type: "mutationBreakpoint", + mutationBreakpoints: this.getMutationBreakpoints(actor), + mutationReason, + }); + } + } + + /** + * Controls whether this DOM document has event listeners attached for + * handling of DOM mutation breakpoints. + * + * @param {Document} rawDoc The DOM document. + */ + _updateDocumentMutationListeners(rawDoc) { + const docMutationBreakpoints = this._mutationBreakpointsForDoc(rawDoc); + if (!docMutationBreakpoints) { + rawDoc.devToolsWatchingDOMMutations = false; + return; + } + + const anyBreakpoint = + docMutationBreakpoints.counts.subtree > 0 || + docMutationBreakpoints.counts.removal > 0 || + docMutationBreakpoints.counts.attribute > 0; + + rawDoc.devToolsWatchingDOMMutations = anyBreakpoint; + + if (docMutationBreakpoints.counts.subtree > 0) { + this.chromeEventHandler.addEventListener( + "devtoolschildinserted", + this.onSubtreeModified, + true /* capture */ + ); + } else { + this.chromeEventHandler.removeEventListener( + "devtoolschildinserted", + this.onSubtreeModified, + true /* capture */ + ); + } + + if (anyBreakpoint) { + this.chromeEventHandler.addEventListener( + "devtoolschildremoved", + this.onNodeRemoved, + true /* capture */ + ); + } else { + this.chromeEventHandler.removeEventListener( + "devtoolschildremoved", + this.onNodeRemoved, + true /* capture */ + ); + } + + if (docMutationBreakpoints.counts.attribute > 0) { + this.chromeEventHandler.addEventListener( + "devtoolsattrmodified", + this.onAttributeModified, + true /* capture */ + ); + } else { + this.chromeEventHandler.removeEventListener( + "devtoolsattrmodified", + this.onAttributeModified, + true /* capture */ + ); + } + } + + _breakOnMutation(mutationType, targetNode, ancestorNode, action) { + this.targetActor.threadActor.pauseForMutationBreakpoint( + mutationType, + targetNode, + ancestorNode, + action + ); + } + + _mutationBreakpointsForDoc(rawDoc, createIfNeeded = false) { + let docMutationBreakpoints = this._mutationBreakpoints.get(rawDoc); + if (!docMutationBreakpoints && createIfNeeded) { + docMutationBreakpoints = { + counts: { + subtree: 0, + removal: 0, + attribute: 0, + }, + nodes: new Map(), + }; + this._mutationBreakpoints.set(rawDoc, docMutationBreakpoints); + } + return docMutationBreakpoints; + } + + _breakpointInfoForNode(target) { + const docMutationBreakpoints = this._mutationBreakpointsForDoc( + target.ownerDocument || target + ); + return ( + (docMutationBreakpoints && docMutationBreakpoints.nodes.get(target)) || + null + ); + } + + onNodeRemoved(evt) { + const mutationBpInfo = this._breakpointInfoForNode(evt.target); + const hasNodeRemovalEvent = mutationBpInfo?.removal; + + this._clearMutationBreakpointsFromSubtree(evt.target); + if (hasNodeRemovalEvent) { + this._breakOnMutation("nodeRemoved", evt.target); + } else { + this.onSubtreeModified(evt); + } + } + + onAttributeModified(evt) { + const mutationBpInfo = this._breakpointInfoForNode(evt.target); + if (mutationBpInfo?.attribute) { + this._breakOnMutation("attributeModified", evt.target); + } + } + + onSubtreeModified(evt) { + const action = evt.type === "devtoolschildinserted" ? "add" : "remove"; + let node = evt.target; + if (node.isNativeAnonymous && !this.showAllAnonymousContent) { + return; + } + while ((node = node.parentNode) !== null) { + const mutationBpInfo = this._breakpointInfoForNode(node); + if (mutationBpInfo?.subtree) { + this._breakOnMutation("subtreeModified", evt.target, node, action); + break; + } + } + } + + _clearMutationBreakpointsFromSubtree(targetNode) { + const targetDoc = targetNode.ownerDocument || targetNode; + const docMutationBreakpoints = this._mutationBreakpointsForDoc(targetDoc); + if (!docMutationBreakpoints || docMutationBreakpoints.nodes.size === 0) { + // Bail early for performance. If the doc has no mutation BPs, there is + // no reason to iterate through the children looking for things to detach. + return; + } + + // The walker is not limited to the subtree of the argument node, so we + // need to ensure that we stop walking when we leave the subtree. + const nextWalkerSibling = this._getNextTraversalSibling(targetNode); + + const walker = new DocumentWalker(targetNode, this.rootWin, { + filter: noAnonymousContentTreeWalkerFilter, + skipTo: SKIP_TO_SIBLING, + }); + + do { + this._updateMutationBreakpointState("detach", walker.currentNode, null); + } while (walker.nextNode() && walker.currentNode !== nextWalkerSibling); + } + + _getNextTraversalSibling(targetNode) { + const walker = new DocumentWalker(targetNode, this.rootWin, { + filter: noAnonymousContentTreeWalkerFilter, + skipTo: SKIP_TO_SIBLING, + }); + + while (!walker.nextSibling()) { + if (!walker.parentNode()) { + // If we try to step past the walker root, there is no next sibling. + return null; + } + } + return walker.currentNode; + } + + /** + * Get any pending mutation records. Must be called by the client after + * the `new-mutations` notification is received. Returns an array of + * mutation records. + * + * Mutation records have a basic structure: + * + * { + * type: attributes|characterData|childList, + * target: <domnode actor ID>, + * } + * + * And additional attributes based on the mutation type: + * + * `attributes` type: + * attributeName: <string> - the attribute that changed + * attributeNamespace: <string> - the attribute's namespace URI, if any. + * newValue: <string> - The new value of the attribute, if any. + * + * `characterData` type: + * newValue: <string> - the new nodeValue for the node + * + * `childList` type is returned when the set of children for a node + * has changed. Includes extra data, which can be used by the client to + * maintain its ownership subtree. + * + * added: array of <domnode actor ID> - The list of actors *previously + * seen by the client* that were added to the target node. + * removed: array of <domnode actor ID> The list of actors *previously + * seen by the client* that were removed from the target node. + * inlineTextChild: If the node now has a single text child, it will + * be sent here. + * + * Actors that are included in a MutationRecord's `removed` but + * not in an `added` have been removed from the client's ownership + * tree (either by being moved under a node the client has seen yet + * or by being removed from the tree entirely), and is considered + * 'orphaned'. + * + * Keep in mind that if a node that the client hasn't seen is moved + * into or out of the target node, it will not be included in the + * removedNodes and addedNodes list, so if the client is interested + * in the new set of children it needs to issue a `children` request. + */ + getMutations(options = {}) { + const pending = this._pendingMutations || []; + this._pendingMutations = []; + this._waitingForGetMutations = false; + + if (options.cleanup) { + for (const node of this._orphaned) { + // Release the orphaned node. Nodes or children that have been + // retained will be moved to this._retainedOrphans. + this.releaseNode(node); + } + this._orphaned = new Set(); + } + + return pending; + } + + queueMutation(mutation) { + if (!this.actorID || this._destroyed) { + // We've been destroyed, don't bother queueing this mutation. + return; + } + + // Add the mutation to the list of mutations to be retrieved next. + this._pendingMutations.push(mutation); + + // Bail out if we already emitted a new-mutations event and are waiting for a client + // to retrieve them. + if (this._waitingForGetMutations) { + return; + } + + if (IMMEDIATE_MUTATIONS.includes(mutation.type)) { + this._emitNewMutations(); + } else { + /** + * If many mutations are fired at the same time, clients might sequentially request + * children/siblings for updated nodes, which can be costly. By throttling the calls + * to getMutations, duplicated mutations will be ignored. + */ + this._throttledEmitNewMutations(); + } + } + + _emitNewMutations() { + if (!this.actorID || this._destroyed) { + // Bail out if the actor was destroyed after throttling this call. + return; + } + + if (this._waitingForGetMutations || !this._pendingMutations.length) { + // Bail out if we already fired the new-mutation event or if no mutations are + // waiting to be retrieved. + return; + } + + this._waitingForGetMutations = true; + this.emit("new-mutations"); + } + + /** + * Handles mutations from the DOM mutation observer API. + * + * @param array[MutationRecord] mutations + * See https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver#MutationRecord + */ + onMutations(mutations) { + // Notify any observers that want *all* mutations (even on nodes that aren't + // referenced). This is not sent over the protocol so can only be used by + // scripts running in the server process. + this.emit("any-mutation"); + + for (const change of mutations) { + const targetActor = this.getNode(change.target); + if (!targetActor) { + continue; + } + const targetNode = change.target; + const type = change.type; + const mutation = { + type, + target: targetActor.actorID, + }; + + if (type === "attributes") { + mutation.attributeName = change.attributeName; + mutation.attributeNamespace = change.attributeNamespace || undefined; + mutation.newValue = targetNode.hasAttribute(mutation.attributeName) + ? targetNode.getAttribute(mutation.attributeName) + : null; + } else if (type === "characterData") { + mutation.newValue = targetNode.nodeValue; + this._maybeQueueInlineTextChildMutation(change, targetNode); + } else if (type === "childList") { + // Get the list of removed and added actors that the client has seen + // so that it can keep its ownership tree up to date. + const removedActors = []; + const addedActors = []; + for (const removed of change.removedNodes) { + const removedActor = this.getNode(removed); + if (!removedActor) { + // If the client never encountered this actor we don't need to + // mention that it was removed. + continue; + } + // While removed from the tree, nodes are saved as orphaned. + this._orphaned.add(removedActor); + removedActors.push(removedActor.actorID); + } + for (const added of change.addedNodes) { + const addedActor = this.getNode(added); + if (!addedActor) { + // If the client never encounted this actor we don't need to tell + // it about its addition for ownership tree purposes - if the + // client wants to see the new nodes it can ask for children. + continue; + } + // The actor is reconnected to the ownership tree, unorphan + // it and let the client know so that its ownership tree is up + // to date. + this._orphaned.delete(addedActor); + addedActors.push(addedActor.actorID); + } + + mutation.numChildren = targetActor.numChildren; + mutation.removed = removedActors; + mutation.added = addedActors; + + const inlineTextChild = this.inlineTextChild(targetActor); + if (inlineTextChild) { + mutation.inlineTextChild = inlineTextChild.form(); + } + } + this.queueMutation(mutation); + } + } + + /** + * Check if the provided mutation could change the way the target element is + * inlined with its parent node. If it might, a custom mutation of type + * "inlineTextChild" will be queued. + * + * @param {MutationRecord} mutation + * A characterData type mutation + */ + _maybeQueueInlineTextChildMutation(mutation) { + const { oldValue, target } = mutation; + const newValue = target.nodeValue; + const limit = gValueSummaryLength; + + if ( + (oldValue.length <= limit && newValue.length <= limit) || + (oldValue.length > limit && newValue.length > limit) + ) { + // Bail out if the new & old values are both below/above the size limit. + return; + } + + const parentActor = this.getNode(target.parentNode); + if (!parentActor || parentActor.rawNode.children.length) { + // If the parent node has other children, a character data mutation will + // not change anything regarding inlining text nodes. + return; + } + + const inlineTextChild = this.inlineTextChild(parentActor); + this.queueMutation({ + type: "inlineTextChild", + target: parentActor.actorID, + inlineTextChild: inlineTextChild ? inlineTextChild.form() : undefined, + }); + } + + onSlotchange(event) { + const target = event.target; + const targetActor = this.getNode(target); + if (!targetActor) { + return; + } + + this.queueMutation({ + type: "slotchange", + target: targetActor.actorID, + }); + } + + /** + * Fires when an anonymous root is created. + * This is needed because regular mutation observers don't fire on some kinds + * of NAC creation. We want to treat this like a regular insertion. + */ + onAnonymousrootcreated(event) { + const root = event.target; + const parent = this.rawParentNode(root); + if (!parent) { + // These events are async. The node might have been removed already, in + // which case there's nothing to do anymore. + return; + } + // By the time onAnonymousrootremoved fires, the node is already detached + // from its parent, so we need to remember it by hand. + this._anonParents.set(root, parent); + this.onMutations([ + { + type: "childList", + target: parent, + addedNodes: [root], + removedNodes: [], + }, + ]); + } + + /** + * @see onAnonymousrootcreated + */ + onAnonymousrootremoved(event) { + const root = event.target; + const parent = this._anonParents.get(root); + if (!parent) { + return; + } + this._anonParents.delete(root); + this.onMutations([ + { + type: "childList", + target: parent, + addedNodes: [], + removedNodes: [root], + }, + ]); + } + + onShadowrootattached(event) { + const actor = this.getNode(event.target); + if (!actor) { + return; + } + + const mutation = { + type: "shadowRootAttached", + target: actor.actorID, + }; + this.queueMutation(mutation); + } + + onFrameLoad({ window, isTopLevel }) { + // By the time we receive the DOMContentLoaded event, we might have been destroyed + if (this._destroyed) { + return; + } + const { readyState } = window.document; + if (readyState != "interactive" && readyState != "complete") { + // The document is not loaded, so we want to register to fire again when the + // DOM has been loaded. + window.addEventListener( + "DOMContentLoaded", + this.onFrameLoad.bind(this, { window, isTopLevel }), + { once: true } + ); + return; + } + + window.document.shadowRootAttachedEventEnabled = true; + + if (isTopLevel) { + // If we initialize the inspector while the document is loading, + // we may already have a root document set in the constructor. + if ( + this.rootDoc && + this.rootDoc !== window.document && + !Cu.isDeadWrapper(this.rootDoc) && + this.rootDoc.defaultView + ) { + this.onFrameUnload({ window: this.rootDoc.defaultView }); + } + // Update all DOM objects references to target the new document. + this.rootWin = window; + this.rootDoc = window.document; + this.rootNode = this.document(); + this.emit("root-available", this.rootNode); + } else { + const frame = getFrameElement(window); + const frameActor = this.getNode(frame); + if (frameActor) { + // If the parent frame is in the map of known node actors, create the + // actor for the new document and emit a root-available event. + const documentActor = this._getOrCreateNodeActor(window.document); + this.emit("root-available", documentActor); + } + } + } + + // Returns true if domNode is in window or a subframe. + _childOfWindow(window, domNode) { + while (domNode) { + const win = nodeDocument(domNode).defaultView; + if (win === window) { + return true; + } + domNode = getFrameElement(win); + } + return false; + } + + onFrameUnload({ window }) { + // Any retained orphans that belong to this document + // or its children need to be released, and a mutation sent + // to notify of that. + const releasedOrphans = []; + + for (const retained of this._retainedOrphans) { + if ( + Cu.isDeadWrapper(retained.rawNode) || + this._childOfWindow(window, retained.rawNode) + ) { + this._retainedOrphans.delete(retained); + releasedOrphans.push(retained.actorID); + this.releaseNode(retained, { force: true }); + } + } + + if (releasedOrphans.length) { + this.queueMutation({ + target: this.rootNode.actorID, + type: "unretained", + nodes: releasedOrphans, + }); + } + + const doc = window.document; + const documentActor = this.getNode(doc); + if (!documentActor) { + return; + } + + // Removing a frame also removes any mutation breakpoints set on that + // document so that clients can clear their set of active breakpoints. + const mutationBps = this._mutationBreakpointsForDoc(doc); + const nodes = mutationBps ? Array.from(mutationBps.nodes.keys()) : []; + for (const node of nodes) { + this._updateMutationBreakpointState("unload", node, null); + } + + this.emit("root-destroyed", documentActor); + + // Cleanup root doc references if we just unloaded the top level root + // document. + if (this.rootDoc === doc) { + this.rootDoc = null; + this.rootNode = null; + } + + // Release the actor for the unloaded document. + this.releaseNode(documentActor, { force: true }); + } + + /** + * Check if a node is attached to the DOM tree of the current page. + * @param {Node} rawNode + * @return {Boolean} false if the node is removed from the tree or within a + * document fragment + */ + _isInDOMTree(rawNode) { + let walker; + try { + walker = this.getDocumentWalker(rawNode); + } catch (e) { + // The DocumentWalker may throw NS_ERROR_ILLEGAL_VALUE when the node isn't found as a legit children of its parent + // ex: <iframe> manually added as immediate child of another <iframe> + if (e.name == "NS_ERROR_ILLEGAL_VALUE") { + return false; + } + throw e; + } + let current = walker.currentNode; + + // Reaching the top of tree + while (walker.parentNode()) { + current = walker.currentNode; + } + + // The top of the tree is a fragment or is not rootDoc, hence rawNode isn't + // attached + if ( + current.nodeType === Node.DOCUMENT_FRAGMENT_NODE || + current !== this.rootDoc + ) { + return false; + } + + // Otherwise the top of the tree is rootDoc, hence rawNode is in rootDoc + return true; + } + + /** + * @see _isInDomTree + */ + isInDOMTree(node) { + if (isNodeDead(node)) { + return false; + } + return this._isInDOMTree(node.rawNode); + } + + /** + * Given a windowID return the NodeActor for the corresponding frameElement, + * unless it's the root window + */ + getNodeActorFromWindowID(windowID) { + let win; + + try { + win = Services.wm.getOuterWindowWithId(windowID); + } catch (e) { + // ignore + } + + if (!win) { + return { + error: "noWindow", + message: "The related docshell is destroyed or not found", + }; + } else if (!win.frameElement) { + // the frame element of the root document is privileged & thus + // inaccessible, so return the document body/element instead + return this.attachElement( + win.document.body || win.document.documentElement + ); + } + + return this.attachElement(win.frameElement); + } + + /** + * Given a contentDomReference return the NodeActor for the corresponding frameElement. + */ + getNodeActorFromContentDomReference(contentDomReference) { + let rawNode = lazy.ContentDOMReference.resolve(contentDomReference); + if (!rawNode || !this._isInDOMTree(rawNode)) { + return null; + } + + // This is a special case for the document object whereby it is considered + // as document.documentElement (the <html> node) + if (rawNode.defaultView && rawNode === rawNode.defaultView.document) { + rawNode = rawNode.documentElement; + } + + return this.attachElement(rawNode); + } + + /** + * Given a StyleSheet resource ID, commonly used in the style-editor, get its + * ownerNode and return the corresponding walker's NodeActor. + * Note that getNodeFromActor was added later and can now be used instead. + */ + getStyleSheetOwnerNode(resourceId) { + const manager = this.targetActor.getStyleSheetsManager(); + const ownerNode = manager.getOwnerNode(resourceId); + return this.attachElement(ownerNode); + } + + /** + * This method can be used to retrieve NodeActor for DOM nodes from other + * actors in a way that they can later be highlighted in the page, or + * selected in the inspector. + * If an actor has a reference to a DOM node, and the UI needs to know about + * this DOM node (and possibly select it in the inspector), the UI should + * first retrieve a reference to the walkerFront: + * + * // Make sure the inspector/walker have been initialized first. + * const inspectorFront = await toolbox.target.getFront("inspector"); + * // Retrieve the walker. + * const walker = inspectorFront.walker; + * + * And then call this method: + * + * // Get the nodeFront from my actor, passing the ID and properties path. + * walker.getNodeFromActor(myActorID, ["element"]).then(nodeFront => { + * // Use the nodeFront, e.g. select the node in the inspector. + * toolbox.getPanel("inspector").selection.setNodeFront(nodeFront); + * }); + * + * @param {String} actorID The ID for the actor that has a reference to the + * DOM node. + * @param {Array} path Where, on the actor, is the DOM node stored. If in the + * scope of the actor, the node is available as `this.data.node`, then this + * should be ["data", "node"]. + * @return {NodeActor} The attached NodeActor, or null if it couldn't be + * found. + */ + getNodeFromActor(actorID, path) { + const actor = this.conn.getActor(actorID); + if (!actor) { + return null; + } + + let obj = actor; + for (const name of path) { + if (!(name in obj)) { + return null; + } + obj = obj[name]; + } + + return this.attachElement(obj); + } + + /** + * Returns an instance of the LayoutActor that is used to retrieve CSS layout-related + * information. + * + * @return {LayoutActor} + */ + getLayoutInspector() { + if (!this.layoutActor) { + this.layoutActor = new LayoutActor(this.conn, this.targetActor, this); + } + + return this.layoutActor; + } + + /** + * Returns the parent grid DOMNode of the given node if it exists, otherwise, it + * returns null. + */ + getParentGridNode(node) { + if (isNodeDead(node)) { + return null; + } + + const parentGridNode = findGridParentContainerForNode(node.rawNode); + return parentGridNode ? this._getOrCreateNodeActor(parentGridNode) : null; + } + + /** + * Returns the offset parent DOMNode of the given node if it exists, otherwise, it + * returns null. + */ + getOffsetParent(node) { + if (isNodeDead(node)) { + return null; + } + + const offsetParent = node.rawNode.offsetParent; + + if (!offsetParent) { + return null; + } + + return this._getOrCreateNodeActor(offsetParent); + } + + getEmbedderElement(browsingContextID) { + const browsingContext = BrowsingContext.get(browsingContextID); + let rawNode = browsingContext.embedderElement; + if (!this._isInDOMTree(rawNode)) { + return null; + } + + // This is a special case for the document object whereby it is considered + // as document.documentElement (the <html> node) + if (rawNode.defaultView && rawNode === rawNode.defaultView.document) { + rawNode = rawNode.documentElement; + } + + return this.attachElement(rawNode); + } + + pick(doFocus, isLocalTab) { + this.nodePicker.pick(doFocus, isLocalTab); + } + + cancelPick() { + this.nodePicker.cancelPick(); + } + + clearPicker() { + this.nodePicker.resetHoveredNodeReference(); + } + + /** + * Given a scrollable node, find its descendants which are causing overflow in it and + * add their raw nodes to the map as keys with the scrollable element as the values. + * + * @param {NodeActor} scrollableNode A scrollable node. + * @param {Map} map The map to which the overflow causing elements are added. + */ + updateOverflowCausingElements(scrollableNode, map) { + if ( + isNodeDead(scrollableNode) || + scrollableNode.rawNode.nodeType !== Node.ELEMENT_NODE + ) { + return; + } + + const overflowCausingChildren = [ + ...InspectorUtils.getOverflowingChildrenOfElement(scrollableNode.rawNode), + ]; + + for (let overflowCausingChild of overflowCausingChildren) { + // overflowCausingChild is a Node, but not necessarily an Element. + // So, get the containing Element + if (overflowCausingChild.nodeType !== Node.ELEMENT_NODE) { + overflowCausingChild = overflowCausingChild.parentElement; + } + map.set(overflowCausingChild, scrollableNode); + } + } + + /** + * Returns an array of the overflow causing elements' NodeActor for the given node. + * + * @param {NodeActor} node The scrollable node. + * @return {Array<NodeActor>} An array of the overflow causing elements. + */ + getOverflowCausingElements(node) { + if ( + isNodeDead(node) || + node.rawNode.nodeType !== Node.ELEMENT_NODE || + !node.isScrollable + ) { + return []; + } + + const overflowCausingElements = [ + ...InspectorUtils.getOverflowingChildrenOfElement(node.rawNode), + ].map(overflowCausingChild => { + if (overflowCausingChild.nodeType !== Node.ELEMENT_NODE) { + overflowCausingChild = overflowCausingChild.parentElement; + } + + return overflowCausingChild; + }); + + return this.attachElements(overflowCausingElements); + } + + /** + * Return the scrollable ancestor node which has overflow because of the given node. + * + * @param {NodeActor} overflowCausingNode + */ + getScrollableAncestorNode(overflowCausingNode) { + if ( + isNodeDead(overflowCausingNode) || + !this.overflowCausingElementsMap.has(overflowCausingNode.rawNode) + ) { + return null; + } + + return this.overflowCausingElementsMap.get(overflowCausingNode.rawNode); + } +} + +exports.WalkerActor = WalkerActor; |