Bug 1449968 - Add support for Inspect Element in Shadow DOM;r=bgrins,emilio draft
authorJulian Descottes <jdescottes@mozilla.com>
Thu, 05 Jul 2018 18:48:24 +0200
changeset 819161 434234c35c68bd16b4c0bc292bc39367222b1f0e
parent 818638 c6304851f5209fe427f62a847aa83aadeb09c4a6
child 819259 802b60075bbed40e2fcd1d2b71fe86549e3b0f40
child 819322 2b26c27cb03cff153e3abf4228cdcce0adaf3d9e
child 819564 4ae908bc9dfb2bbc951e9fda39049eb150a1df86
push id116442
push userjdescottes@mozilla.com
push dateTue, 17 Jul 2018 07:55:16 +0000
reviewersbgrins, emilio
bugs1449968
milestone63.0a1
Bug 1449968 - Add support for Inspect Element in Shadow DOM;r=bgrins,emilio MozReview-Commit-ID: HhwyDMyZe1k
browser/modules/ContextMenu.jsm
devtools/client/framework/devtools.js
devtools/client/inspector/markup/test/browser.ini
devtools/client/inspector/markup/test/browser_markup_shadowdom_nested_pick_inspect.js
devtools/client/inspector/markup/test/browser_markup_shadowdom_pick_nested.js
devtools/server/actors/inspector/walker.js
toolkit/modules/css-selector.js
--- a/browser/modules/ContextMenu.jsm
+++ b/browser/modules/ContextMenu.jsm
@@ -11,17 +11,17 @@ var EXPORTED_SYMBOLS = ["ContextMenu"];
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   E10SUtils: "resource://gre/modules/E10SUtils.jsm",
   BrowserUtils: "resource://gre/modules/BrowserUtils.jsm",
-  findCssSelector: "resource://gre/modules/css-selector.js",
+  findAllCssSelectors: "resource://gre/modules/css-selector.js",
   SpellCheckHelper: "resource://gre/modules/InlineSpellChecker.jsm",
   LoginManagerContent: "resource://gre/modules/LoginManagerContent.jsm",
   WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.jsm",
   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
   InlineSpellCheckerContent: "resource://gre/modules/InlineSpellCheckerContent.jsm",
 });
 
 XPCOMUtils.defineLazyGetter(this, "PageMenuChild", () => {
@@ -448,44 +448,16 @@ class ContextMenu {
 
     if (!request) {
       return true;
     }
 
     return false;
   }
 
-  /**
-   * Retrieve the array of CSS selectors corresponding to the provided node.
-   *
-   * The selectors are ordered starting with the root document and ending with the deepest
-   * nested frame. Additional items are used if the node is inside a frame, each
-   * representing the CSS selector for finding the frame element in its parent document.
-   *
-   * This format is expected by DevTools in order to handle the Inspect Node context menu
-   * item.
-   *
-   * @param  {aNode}
-   *         The node for which the CSS selectors should be computed
-   * @return {Array}
-   *         An array of CSS selectors to find the target node. Several selectors can be
-   *         needed if the element is nested in frames and not directly in the root
-   *         document. The selectors are ordered starting with the root document and
-   *         ending with the deepest nested frame.
-   */
-  _getNodeSelectors(aNode) {
-    let selectors = [];
-    while (aNode) {
-      selectors.unshift(findCssSelector(aNode));
-      aNode = aNode.ownerGlobal.frameElement;
-    }
-
-    return selectors;
-  }
-
   handleEvent(aEvent) {
     let defaultPrevented = aEvent.defaultPrevented;
 
     if (!Services.prefs.getBoolPref("dom.event.contextmenu.enabled")) {
       let plugin = null;
 
       try {
         plugin = aEvent.composedTarget.QueryInterface(Ci.nsIObjectLoadingContent);
@@ -552,17 +524,17 @@ class ContextMenu {
           contentDisposition = props.get("content-disposition", Ci.nsISupportsCString).data;
         } catch (e) {}
       } catch (e) {}
     }
 
     let selectionInfo = BrowserUtils.getSelectionDetails(this.content);
     let loadContext = this.global.docShell.QueryInterface(Ci.nsILoadContext);
     let userContextId = loadContext.originAttributes.userContextId;
-    let popupNodeSelectors = this._getNodeSelectors(aEvent.composedTarget);
+    let popupNodeSelectors = findAllCssSelectors(aEvent.composedTarget);
 
     this._setContext(aEvent);
     let context = this.context;
     this.target = context.target;
 
     let spellInfo = null;
     let editFlags = null;
     let principal = null;
--- a/devtools/client/framework/devtools.js
+++ b/devtools/client/framework/devtools.js
@@ -645,18 +645,25 @@ DevTools.prototype = {
     async function querySelectors(nodeFront) {
       const selector = nodeSelectors.shift();
       if (!selector) {
         return nodeFront;
       }
       nodeFront = await walker.querySelector(nodeFront, selector);
       if (nodeSelectors.length > 0) {
         const { nodes } = await walker.children(nodeFront);
-        // This is the NodeFront for the document node inside the iframe
-        nodeFront = nodes[0];
+        // If there are remaining selectors to process, they will target a document or a
+        // document-fragment under the current node. Whether the element is a frame or
+        // a web component, it can only contain one document/document-fragment, so just
+        // select the first one available.
+        nodeFront = nodes.find(node => {
+          const { nodeType } = node;
+          return nodeType === Node.DOCUMENT_FRAGMENT_NODE ||
+                 nodeType === Node.DOCUMENT_NODE;
+        });
       }
       return querySelectors(nodeFront);
     }
     const nodeFront = await walker.getRootNode();
     return querySelectors(nodeFront);
   },
 
   /**
--- a/devtools/client/inspector/markup/test/browser.ini
+++ b/devtools/client/inspector/markup/test/browser.ini
@@ -168,18 +168,18 @@ skip-if = e10s # Bug 1036409 - The last 
 [browser_markup_shadowdom_clickreveal.js]
 [browser_markup_shadowdom_clickreveal_scroll.js]
 [browser_markup_shadowdom_delete.js]
 [browser_markup_shadowdom_dynamic.js]
 [browser_markup_shadowdom_hover.js]
 [browser_markup_shadowdom_maxchildren.js]
 [browser_markup_shadowdom_mutations_shadow.js]
 [browser_markup_shadowdom_navigation.js]
+[browser_markup_shadowdom_nested_pick_inspect.js]
 [browser_markup_shadowdom_noslot.js]
-[browser_markup_shadowdom_pick_nested.js]
 [browser_markup_shadowdom_slotupdate.js]
 [browser_markup_tag_delete_whitespace_node.js]
 [browser_markup_tag_edit_01.js]
 [browser_markup_tag_edit_02.js]
 [browser_markup_tag_edit_03.js]
 [browser_markup_tag_edit_04-backspace.js]
 [browser_markup_tag_edit_04-delete.js]
 [browser_markup_tag_edit_05.js]
rename from devtools/client/inspector/markup/test/browser_markup_shadowdom_pick_nested.js
rename to devtools/client/inspector/markup/test/browser_markup_shadowdom_nested_pick_inspect.js
--- a/devtools/client/inspector/markup/test/browser_markup_shadowdom_pick_nested.js
+++ b/devtools/client/inspector/markup/test/browser_markup_shadowdom_nested_pick_inspect.js
@@ -1,16 +1,19 @@
 /* vim: set ts=2 et sw=2 tw=80: */
 /* Any copyright is dedicated to the Public Domain.
  http://creativecommons.org/publicdomain/zero/1.0/ */
+/* globals getTestActorWithoutToolbox */
 
 "use strict";
 
-// Test that using the element picker on nodes inside a shadow root expands the markup
-// view as expected and that the correct markup container is selected.
+// Test that the markup view is correctly expanded when inspecting an element nested
+// in several shadow roots:
+// - when using the context-menu "Inspect element"
+// - when using the element picker
 
 const TEST_URL = `data:text/html;charset=utf-8,` + encodeURIComponent(`
   <test-outer></test-outer>
   <script>
   (function() {
     'use strict';
 
     function defineComponent(name, html) {
@@ -33,31 +36,47 @@ const TEST_URL = `data:text/html;charset
         <div>
           <div>
             <slot></slot>
           </div>
         </div>
       </div>\`);
 
     defineComponent('test-image',
-      \`<div style="display:block; height: 200px; width: 200px; background:red"></div>\`);
+      \`<div style="display:block; height: 200px; width: 100%; background:red"></div>\`);
   })();
   </script>`);
 
 add_task(async function() {
   await enableWebComponents();
 
-  const { inspector, toolbox, testActor } = await openInspectorForURL(TEST_URL);
+  const { inspector, toolbox, tab, testActor } = await openInspectorForURL(TEST_URL);
+
+  info("Waiting for element picker to become active");
+  await startPicker(toolbox);
+  info("Click and pick the pick-target");
+  await pickElement(inspector, testActor, "test-outer", 10, 10);
+  info("Check that the markup view is displayed as expected");
+  await assertMarkupView(inspector);
+
+  info("Close DevTools before testing Inspect Element");
+  await gDevTools.closeToolbox(inspector.target);
 
   info("Waiting for element picker to become active.");
-  await startPicker(toolbox);
+  const newTestActor = await getTestActorWithoutToolbox(tab);
+  info("Click on Inspect Element for our test-image <div>");
+  // Note: we click on test-outer, because we can't find the <div> using a simple
+  // querySelector. However the click is simulated in the middle of the <test-outer>
+  // component, and will always hit the test <div> which takes all the space.
+  const newInspector = await clickOnInspectMenuItem(newTestActor, "test-outer");
+  info("Check again that the markup view is displayed as expected");
+  await assertMarkupView(newInspector);
+});
 
-  info("Click and pick the pick-target");
-  await pickElement(inspector, testActor, "test-outer", 10, 10);
-
+async function assertMarkupView(inspector) {
   const outerFront = await getNodeFront("test-outer", inspector);
   const outerContainer = inspector.markup.getContainer(outerFront);
   assertContainer(outerContainer, {expanded: true, text: "test-outer", children: 1});
 
   const outerShadowContainer = outerContainer.getChildContainers()[0];
   assertContainer(outerShadowContainer,
     {expanded: true, text: "#shadow-root", children: 1});
 
@@ -71,17 +90,17 @@ add_task(async function() {
 
   const imageShadowContainer = imageContainer.getChildContainers()[0];
   assertContainer(imageShadowContainer,
     {expanded: true, text: "#shadow-root", children: 1});
 
   const redDivContainer = imageShadowContainer.getChildContainers()[0];
   assertContainer(redDivContainer, {expanded: false, text: "div"});
   is(redDivContainer.selected, true, "Div element is selected as expected");
-});
+}
 
 /**
  * Check if the provided markup container is expanded, has the expected text and the
  * expected number of children.
  */
 function assertContainer(container, {expanded, text, children}) {
   is(container.expanded, expanded, "Container is expanded");
   assertContainerHasText(container, text);
--- a/devtools/server/actors/inspector/walker.js
+++ b/devtools/server/actors/inspector/walker.js
@@ -734,16 +734,23 @@ var WalkerActor = protocol.ActorClassWit
       if (options.center) {
         start = options.center.rawNode;
       } else if (options.start) {
         start = options.start.rawNode;
       } else {
         start = firstChild;
       }
 
+      // A shadow root is not included in the children returned by the walker, so we can
+      // not use it as start node. However it will be displayed as the first node, so
+      // we use firstChild as a fallback.
+      if (isShadowRoot(start)) {
+        start = firstChild;
+      }
+
       // Start by reading backward from the starting point if we're centering...
       const backwardWalker = getFilteredWalker(start);
       if (backwardWalker.currentNode != firstChild && options.center) {
         backwardWalker.previousSibling();
         const backwardCount = Math.floor(maxNodes / 2);
         const backwardNodes = this._readBackward(backwardWalker, backwardCount);
         nodes = backwardNodes;
       }
--- a/toolkit/modules/css-selector.js
+++ b/toolkit/modules/css-selector.js
@@ -1,72 +1,117 @@
 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set ts=2 et sw=2 tw=80: */
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-var EXPORTED_SYMBOLS = ["findCssSelector"];
+var EXPORTED_SYMBOLS = ["findAllCssSelectors", "findCssSelector"];
 
 /**
  * Traverse getBindingParent until arriving upon the bound element
  * responsible for the generation of the specified node.
  * See https://developer.mozilla.org/en-US/docs/XBL/XBL_1.0_Reference/DOM_Interfaces#getBindingParent.
  *
  * @param {DOMNode} node
  * @return {DOMNode}
  *         If node is not anonymous, this will return node. Otherwise,
  *         it will return the bound element
  *
  */
 function getRootBindingParent(node) {
-  let parent;
   let doc = node.ownerDocument;
   if (!doc) {
     return node;
   }
+
+  if (getShadowRoot(node)) {
+    // If the node is under a shadow root, the shadow host is the "binding
+    // parent" but we can create a CSS selector between the shadow root and the
+    // node, so return immediately.
+    return node;
+  }
+
+  let parent;
   while ((parent = doc.getBindingParent(node))) {
     node = parent;
   }
   return node;
 }
 
 /**
+ * Return the node's parent shadow root if the node in shadow DOM, null
+ * otherwise.
+ */
+function getShadowRoot(node) {
+  let doc = node.ownerDocument;
+  if (!doc) {
+    return null;
+  }
+
+  const parent = doc.getBindingParent(node);
+  const shadowRoot = parent && parent.openOrClosedShadowRoot;
+  if (shadowRoot) {
+    return shadowRoot;
+  }
+
+  return null;
+}
+
+/**
  * Find the position of [element] in [nodeList].
  * @returns an index of the match, or -1 if there is no match
  */
 function positionInNodeList(element, nodeList) {
   for (let i = 0; i < nodeList.length; i++) {
     if (element === nodeList[i]) {
       return i;
     }
   }
   return -1;
 }
 
 /**
+ * Retrieve the document or shadow-root containing the provided node. This will
+ * be the topmost element from which the node can be retrieved using
+ * querySelectorAll and consequently where to start creating a css selector.
+ */
+function getDocumentOrShadowRoot(node) {
+  const shadowRoot = getShadowRoot(node);
+  if (shadowRoot) {
+    // If the node is under a shadow root, return the corresponding
+    // document-fragment.
+    return shadowRoot;
+  }
+
+  // Otherwise return the ownerDocument.
+  return node.ownerDocument;
+}
+
+/**
  * Find a unique CSS selector for a given element
  * @returns a string such that ele.ownerDocument.querySelector(reply) === ele
  * and ele.ownerDocument.querySelectorAll(reply).length === 1
  */
 const findCssSelector = function(ele) {
   ele = getRootBindingParent(ele);
-  let document = ele.ownerDocument;
-  if (!document || !document.contains(ele)) {
+
+  let containingDocOrShadow = getDocumentOrShadowRoot(ele);
+  if (!containingDocOrShadow || !containingDocOrShadow.contains(ele)) {
     // findCssSelector received element not inside document.
     return "";
   }
 
   let cssEscape = ele.ownerGlobal.CSS.escape;
 
   // document.querySelectorAll("#id") returns multiple if elements share an ID
   if (ele.id &&
-      document.querySelectorAll("#" + cssEscape(ele.id)).length === 1) {
+      containingDocOrShadow.querySelectorAll("#" + cssEscape(ele.id)).length === 1) {
     return "#" + cssEscape(ele.id);
   }
 
   // Inherently unique by tag name
   let tagName = ele.localName;
   if (tagName === "html") {
     return "html";
   }
@@ -74,42 +119,82 @@ const findCssSelector = function(ele) {
     return "head";
   }
   if (tagName === "body") {
     return "body";
   }
 
   // We might be able to find a unique class name
   let selector, index, matches;
-  if (ele.classList.length > 0) {
-    for (let i = 0; i < ele.classList.length; i++) {
-      // Is this className unique by itself?
-      selector = "." + cssEscape(ele.classList.item(i));
-      matches = document.querySelectorAll(selector);
-      if (matches.length === 1) {
-        return selector;
-      }
-      // Maybe it's unique with a tag name?
-      selector = cssEscape(tagName) + selector;
-      matches = document.querySelectorAll(selector);
-      if (matches.length === 1) {
-        return selector;
-      }
-      // Maybe it's unique using a tag name and nth-child
-      index = positionInNodeList(ele, ele.parentNode.children) + 1;
-      selector = selector + ":nth-child(" + index + ")";
-      matches = document.querySelectorAll(selector);
-      if (matches.length === 1) {
-        return selector;
-      }
+  for (let i = 0; i < ele.classList.length; i++) {
+    // Is this className unique by itself?
+    selector = "." + cssEscape(ele.classList.item(i));
+    matches = containingDocOrShadow.querySelectorAll(selector);
+    if (matches.length === 1) {
+      return selector;
+    }
+    // Maybe it's unique with a tag name?
+    selector = cssEscape(tagName) + selector;
+    matches = containingDocOrShadow.querySelectorAll(selector);
+    if (matches.length === 1) {
+      return selector;
+    }
+    // Maybe it's unique using a tag name and nth-child
+    index = positionInNodeList(ele, ele.parentNode.children) + 1;
+    selector = selector + ":nth-child(" + index + ")";
+    matches = containingDocOrShadow.querySelectorAll(selector);
+    if (matches.length === 1) {
+      return selector;
     }
   }
 
-  // Not unique enough yet.  As long as it's not a child of the document,
-  // continue recursing up until it is unique enough.
-  if (ele.parentNode !== document) {
-    index = positionInNodeList(ele, ele.parentNode.children) + 1;
-    selector = findCssSelector(ele.parentNode) + " > " +
-      cssEscape(tagName) + ":nth-child(" + index + ")";
+  // Not unique enough yet.
+  index = positionInNodeList(ele, ele.parentNode.children) + 1;
+  selector = cssEscape(tagName) + ":nth-child(" + index + ")";
+  if (ele.parentNode !== containingDocOrShadow) {
+    selector = findCssSelector(ele.parentNode) + " > " + selector;
+  }
+  return selector;
+};
+
+/**
+ * If the element is in a frame or under a shadowRoot, return the corresponding
+ * element.
+ */
+function getSelectorParent(node) {
+  const shadowRoot = getShadowRoot(node);
+  if (shadowRoot) {
+    // The element is in a shadowRoot, return the host component.
+    return shadowRoot.host;
   }
 
-  return selector;
+  // Otherwise return the parent frameElement.
+  return node.ownerGlobal.frameElement;
+}
+
+/**
+ * Retrieve the array of CSS selectors corresponding to the provided node.
+ *
+ * The selectors are ordered starting with the root document and ending with the deepest
+ * nested frame. Additional items are used if the node is inside a frame or a shadow root,
+ * each representing the CSS selector for finding the frame or root element in its parent
+ * document.
+ *
+ * This format is expected by DevTools in order to handle the Inspect Node context menu
+ * item.
+ *
+ * @param  {node}
+ *         The node for which the CSS selectors should be computed
+ * @return {Array}
+ *         An array of CSS selectors to find the target node. Several selectors can be
+ *         needed if the element is nested in frames and not directly in the root
+ *         document. The selectors are ordered starting with the root document and
+ *         ending with the deepest nested frame or shadow root.
+ */
+const findAllCssSelectors = function(node) {
+  let selectors = [];
+  while (node) {
+    selectors.unshift(findCssSelector(node));
+    node = getSelectorParent(node);
+  }
+
+  return selectors;
 };