diff options
Diffstat (limited to '')
-rw-r--r-- | devtools/server/actors/inspector/node.js | 861 |
1 files changed, 861 insertions, 0 deletions
diff --git a/devtools/server/actors/inspector/node.js b/devtools/server/actors/inspector/node.js new file mode 100644 index 0000000000..294e3e9564 --- /dev/null +++ b/devtools/server/actors/inspector/node.js @@ -0,0 +1,861 @@ +/* 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; + this.currentContainerType = this.containerType; + + 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), + containerType: this.containerType, + + // 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; + } + + /** + * Returns the computed containerType style property value of the node. + */ + get containerType() { + // non-element nodes can't be containers + if ( + isNodeDead(this) || + this.rawNode.nodeType !== Node.ELEMENT_NODE || + !this.computedStyle + ) { + return null; + } + + return this.computedStyle.containerType; + } + + /** + * 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; + } + + // NOTE: Debugger.Script.prototype.startColumn is 1-based. + // Convert to 0-based, while keeping the wasm's column (1) as is. + // (bug 1863878) + const columnBase = customElementDO.script.format === "wasm" ? 0 : 1; + + return { + url: customElementDO.script.url, + line: customElementDO.script.startLine, + column: customElementDO.script.startColumn - columnBase, + }; + } + + /** + * 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; |