Bug 1323700 - Adding a copy full path option to the inspector menu; r=gl draft
authorPatrick Brosset <pbrosset@mozilla.com>
Tue, 17 Jan 2017 14:14:41 +0100
changeset 463073 360b3e72c0983c749eac9e685f29ee879cf3034a
parent 462402 3e275d37a06236981bff399b7d7aa0646be3fee7
child 542560 1e66718a7212fa5450a41b245eed45d7e5ffb618
push id41939
push userbmo:pbrosset@mozilla.com
push dateWed, 18 Jan 2017 12:36:10 +0000
reviewersgl
bugs1323700
milestone53.0a1
Bug 1323700 - Adding a copy full path option to the inspector menu; r=gl MozReview-Commit-ID: IvRnek7e7Xq
devtools/client/inspector/inspector.js
devtools/client/inspector/test/browser_inspector_menu-01-sensitivity.js
devtools/client/inspector/test/browser_inspector_menu-02-copy-items.js
devtools/client/locales/en-US/inspector.properties
devtools/client/shared/telemetry.js
devtools/server/actors/inspector.js
devtools/server/actors/root.js
devtools/shared/inspector/css-logic.js
devtools/shared/specs/node.js
devtools/shared/tests/mochitest/chrome.ini
devtools/shared/tests/mochitest/test_css-logic-getCssPath.html
--- a/devtools/client/inspector/inspector.js
+++ b/devtools/client/inspector/inspector.js
@@ -164,16 +164,20 @@ Inspector.prototype = {
   get hasUrlToImageDataResolver() {
     return this._target.client.traits.urlToImageDataResolver;
   },
 
   get canGetUniqueSelector() {
     return this._target.client.traits.getUniqueSelector;
   },
 
+  get canGetCssPath() {
+    return this._target.client.traits.getCssPath;
+  },
+
   get canGetUsedFontFaces() {
     return this._target.client.traits.getUsedFontFaces;
   },
 
   get canPasteInnerOrAdjacentHTML() {
     return this._target.client.traits.pasteHTML;
   },
 
@@ -1108,16 +1112,25 @@ Inspector.prototype = {
       label: INSPECTOR_L10N.getStr("inspectorCopyCSSSelector.label"),
       accesskey:
         INSPECTOR_L10N.getStr("inspectorCopyCSSSelector.accesskey"),
       disabled: !isSelectionElement,
       hidden: !this.canGetUniqueSelector,
       click: () => this.copyUniqueSelector(),
     }));
     copySubmenu.append(new MenuItem({
+      id: "node-menu-copycsspath",
+      label: INSPECTOR_L10N.getStr("inspectorCopyCSSPath.label"),
+      accesskey:
+        INSPECTOR_L10N.getStr("inspectorCopyCSSPath.accesskey"),
+      disabled: !isSelectionElement,
+      hidden: !this.canGetCssPath,
+      click: () => this.copyCssPath(),
+    }));
+    copySubmenu.append(new MenuItem({
       id: "node-menu-copyimagedatauri",
       label: INSPECTOR_L10N.getStr("inspectorImageDataUri.label"),
       disabled: !isSelectionElement || !markupContainer ||
                 !markupContainer.isPreviewable(),
       click: () => this.copyImageDataUri(),
     }));
 
     menu.append(new MenuItem({
@@ -1710,19 +1723,34 @@ Inspector.prototype = {
   /**
    * Copy a unique selector of the selected Node to the clipboard.
    */
   copyUniqueSelector: function () {
     if (!this.selection.isNode()) {
       return;
     }
 
-    this.selection.nodeFront.getUniqueSelector().then((selector) => {
+    this.telemetry.toolOpened("copyuniquecssselector");
+    this.selection.nodeFront.getUniqueSelector().then(selector => {
       clipboardHelper.copyString(selector);
-    }).then(null, console.error);
+    }).catch(e => console.error);
+  },
+
+  /**
+   * Copy the full CSS Path of the selected Node to the clipboard.
+   */
+  copyCssPath: function () {
+    if (!this.selection.isNode()) {
+      return;
+    }
+
+    this.telemetry.toolOpened("copyfullcssselector");
+    this.selection.nodeFront.getCssPath().then(path => {
+      clipboardHelper.copyString(path);
+    }).catch(e => console.error);
   },
 
   /**
    * Initiate gcli screenshot command on selected node.
    */
   screenshotNode: function () {
     const command = Services.prefs.getBoolPref("devtools.screenshot.clipboard.enabled") ?
       "screenshot --file --clipboard --selector" :
--- a/devtools/client/inspector/test/browser_inspector_menu-01-sensitivity.js
+++ b/devtools/client/inspector/test/browser_inspector_menu-01-sensitivity.js
@@ -21,16 +21,17 @@ const ACTIVE_ON_DOCTYPE_ITEMS = [
   "node-menu-useinconsole"
 ];
 
 const ALL_MENU_ITEMS = [
   "node-menu-edithtml",
   "node-menu-copyinner",
   "node-menu-copyouter",
   "node-menu-copyuniqueselector",
+  "node-menu-copycsspath",
   "node-menu-copyimagedatauri",
   "node-menu-delete",
   "node-menu-pseudo-hover",
   "node-menu-pseudo-active",
   "node-menu-pseudo-focus",
   "node-menu-scrollnodeintoview",
   "node-menu-screenshotnode",
   "node-menu-add-attribute",
--- a/devtools/client/inspector/test/browser_inspector_menu-02-copy-items.js
+++ b/devtools/client/inspector/test/browser_inspector_menu-02-copy-items.js
@@ -21,16 +21,22 @@ const COPY_ITEMS_TEST_DATA = [
   },
   {
     desc: "copy unique selector",
     id: "node-menu-copyuniqueselector",
     selector: "[data-id=\"copy\"]",
     text: "body > div:nth-child(1) > p:nth-child(2)",
   },
   {
+    desc: "copy css path",
+    id: "node-menu-copycsspath",
+    selector: "[data-id=\"copy\"]",
+    text: "html body div p",
+  },
+  {
     desc: "copy image data uri",
     id: "node-menu-copyimagedatauri",
     selector: "#copyimage",
     text: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABC" +
       "AAAAAA6fptVAAAACklEQVQYV2P4DwABAQEAWk1v8QAAAABJRU5ErkJggg==",
   },
 ];
 
--- a/devtools/client/locales/en-US/inspector.properties
+++ b/devtools/client/locales/en-US/inspector.properties
@@ -155,16 +155,22 @@ inspectorCopyOuterHTML.label=Outer HTML
 inspectorCopyOuterHTML.accesskey=O
 
 # LOCALIZATION NOTE (inspectorCopyCSSSelector.label): This is the label
 # shown in the inspector contextual-menu for the item that lets users copy
 # the CSS Selector of the current node
 inspectorCopyCSSSelector.label=CSS Selector
 inspectorCopyCSSSelector.accesskey=S
 
+# LOCALIZATION NOTE (inspectorCopyCSSPath.label): This is the label
+# shown in the inspector contextual-menu for the item that lets users copy
+# the full CSS path of the current node
+inspectorCopyCSSPath.label=CSS Path
+inspectorCopyCSSPath.accesskey=P
+
 # LOCALIZATION NOTE (inspectorPasteOuterHTML.label): This is the label shown
 # in the inspector contextual-menu for the item that lets users paste outer
 # HTML in the current node
 inspectorPasteOuterHTML.label=Outer HTML
 inspectorPasteOuterHTML.accesskey=O
 
 # LOCALIZATION NOTE (inspectorPasteInnerHTML.label): This is the label shown
 # in the inspector contextual-menu for the item that lets users paste inner
--- a/devtools/client/shared/telemetry.js
+++ b/devtools/client/shared/telemetry.js
@@ -157,16 +157,22 @@ Telemetry.prototype = {
       histogram: "DEVTOOLS_MENU_EYEDROPPER_OPENED_COUNT",
     },
     pickereyedropper: {
       histogram: "DEVTOOLS_PICKER_EYEDROPPER_OPENED_COUNT",
     },
     toolbareyedropper: {
       histogram: "DEVTOOLS_TOOLBAR_EYEDROPPER_OPENED_COUNT",
     },
+    copyuniquecssselector: {
+      histogram: "DEVTOOLS_COPY_UNIQUE_CSS_SELECTOR_OPENED_COUNT",
+    },
+    copyfullcssselector: {
+      histogram: "DEVTOOLS_COPY_FULL_CSS_SELECTOR_OPENED_COUNT",
+    },
     developertoolbar: {
       histogram: "DEVTOOLS_DEVELOPERTOOLBAR_OPENED_COUNT",
       timerHistogram: "DEVTOOLS_DEVELOPERTOOLBAR_TIME_ACTIVE_SECONDS"
     },
     aboutdebugging: {
       histogram: "DEVTOOLS_ABOUTDEBUGGING_OPENED_COUNT",
       timerHistogram: "DEVTOOLS_ABOUTDEBUGGING_TIME_ACTIVE_SECONDS"
     },
--- a/devtools/server/actors/inspector.js
+++ b/devtools/server/actors/inspector.js
@@ -146,16 +146,17 @@ loader.lazyGetter(this, "DOMParser", fun
 
 loader.lazyGetter(this, "eventListenerService", function () {
   return Cc["@mozilla.org/eventlistenerservice;1"]
            .getService(Ci.nsIEventListenerService);
 });
 
 loader.lazyRequireGetter(this, "CssLogic", "devtools/server/css-logic", true);
 loader.lazyRequireGetter(this, "findCssSelector", "devtools/shared/inspector/css-logic", true);
+loader.lazyRequireGetter(this, "getCssPath", "devtools/shared/inspector/css-logic", true);
 
 /**
  * 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;
 
@@ -641,16 +642,28 @@ var NodeActor = exports.NodeActor = prot
   getUniqueSelector: function () {
     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: function () {
+    if (Cu.isDeadWrapper(this.rawNode)) {
+      return "";
+    }
+    return getCssPath(this.rawNode);
+  },
+
+  /**
    * Scroll the selected node into view.
    */
   scrollIntoView: function () {
     this.rawNode.scrollIntoView(true);
   },
 
   /**
    * Get the node's image data if any (for canvas and img nodes).
--- a/devtools/server/actors/root.js
+++ b/devtools/server/actors/root.js
@@ -142,16 +142,18 @@ RootActor.prototype = {
     // Whether the style rule actor implements the modifySelector method
     // that modifies the rule's selector
     selectorEditable: true,
     // Whether the page style actor implements the addNewRule method that
     // adds new rules to the page
     addNewRule: true,
     // Whether the dom node actor implements the getUniqueSelector method
     getUniqueSelector: true,
+    // Whether the dom node actor implements the getCssPath method
+    getCssPath: true,
     // Whether the director scripts are supported
     directorScripts: true,
     // Whether the debugger server supports
     // blackboxing/pretty-printing (not supported in Fever Dream yet)
     noBlackBoxing: false,
     noPrettyPrinting: false,
     // Whether the page style actor implements the getUsedFontFaces method
     // that returns the font faces used on a node
--- a/devtools/shared/inspector/css-logic.js
+++ b/devtools/shared/inspector/css-logic.js
@@ -405,8 +405,58 @@ function findCssSelector(ele) {
     index = positionInNodeList(ele, ele.parentNode.children) + 1;
     selector = findCssSelector(ele.parentNode) + " > " +
       tagName + ":nth-child(" + index + ")";
   }
 
   return selector;
 }
 exports.findCssSelector = findCssSelector;
+
+/**
+ * Get the full CSS path for a given element.
+ * @returns a string that can be used as a CSS selector for the element. It might not
+ * match the element uniquely. It does however, represent the full path from the root
+ * node to the element.
+ */
+function getCssPath(ele) {
+  ele = getRootBindingParent(ele);
+  const document = ele.ownerDocument;
+  if (!document || !document.contains(ele)) {
+    throw new Error("getCssPath received element not inside document");
+  }
+
+  const getElementSelector = element => {
+    if (!element.localName) {
+      return "";
+    }
+
+    let label = element.nodeName == element.nodeName.toUpperCase()
+                ? element.localName.toLowerCase()
+                : element.localName;
+
+    if (element.id) {
+      label += "#" + element.id;
+    }
+
+    if (element.classList) {
+      for (let cl of element.classList) {
+        label += "." + cl;
+      }
+    }
+
+    return label;
+  };
+
+  let paths = [];
+
+  while (ele) {
+    if (!ele || ele.nodeType !== Node.ELEMENT_NODE) {
+      break;
+    }
+
+    paths.splice(0, 0, getElementSelector(ele));
+    ele = ele.parentNode;
+  }
+
+  return paths.length ? paths.join(" ") : "";
+}
+exports.getCssPath = getCssPath;
--- a/devtools/shared/specs/node.js
+++ b/devtools/shared/specs/node.js
@@ -32,16 +32,22 @@ const nodeSpec = generateActorSpec({
       response: {}
     },
     getUniqueSelector: {
       request: {},
       response: {
         value: RetVal("string")
       }
     },
+    getCssPath: {
+      request: {},
+      response: {
+        value: RetVal("string")
+      }
+    },
     scrollIntoView: {
       request: {},
       response: {}
     },
     getImageData: {
       request: {maxDim: Arg(0, "nullable:number")},
       response: RetVal("imageData")
     },
--- a/devtools/shared/tests/mochitest/chrome.ini
+++ b/devtools/shared/tests/mochitest/chrome.ini
@@ -1,8 +1,9 @@
 [DEFAULT]
 tags = devtools
 skip-if = os == 'android'
 
-[test_eventemitter_basic.html]
+[test_css-logic-getCssPath.html]
+[test_css-logic.html]
 [test_devtools_extensions.html]
+[test_eventemitter_basic.html]
 skip-if = os == 'linux' && debug # Bug 1205739
-[test_css-logic.html]
new file mode 100644
--- /dev/null
+++ b/devtools/shared/tests/mochitest/test_css-logic-getCssPath.html
@@ -0,0 +1,121 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1323700
+-->
+<head>
+  <meta charset="utf-8">
+  <title>Test for Bug 1323700</title>
+
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+  <script type="application/javascript;version=1.8">
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+let { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
+const CssLogic = require("devtools/shared/inspector/css-logic");
+
+var _tests = [];
+function addTest(test) {
+  _tests.push(test);
+}
+
+function runNextTest() {
+  if (_tests.length == 0) {
+    SimpleTest.finish()
+    return;
+  }
+  _tests.shift()();
+}
+
+window.onload = function() {
+  SimpleTest.waitForExplicitFinish();
+  runNextTest();
+}
+
+addTest(function getCssPathForUnattachedElement() {
+  var unattached = document.createElement("div");
+  unattached.id = "unattached";
+  try {
+    CssLogic.getCssPath(unattached);
+    ok(false, "Unattached node did not throw")
+  } catch(e) {
+    ok(e, "Unattached node throws an exception");
+  }
+
+  var unattachedChild = document.createElement("div");
+  unattached.appendChild(unattachedChild);
+  try {
+    CssLogic.getCssPath(unattachedChild);
+    ok(false, "Unattached child node did not throw")
+  } catch(e) {
+    ok(e, "Unattached child node throws an exception");
+  }
+
+  var unattachedBody = document.createElement("body");
+  try {
+    CssLogic.getCssPath(unattachedBody);
+    ok(false, "Unattached body node did not throw")
+  } catch(e) {
+    ok(e, "Unattached body node throws an exception");
+  }
+
+  runNextTest();
+});
+
+addTest(function cssPathHasOneStepForEachAncestor() {
+  for (let el of [...document.querySelectorAll('*')]) {
+    let splitPath = CssLogic.getCssPath(el).split(" ");
+
+    let expectedNbOfParts = 0;
+    var parent = el.parentNode;
+    while (parent) {
+      expectedNbOfParts ++;
+      parent = parent.parentNode;
+    }
+
+    is(splitPath.length, expectedNbOfParts, "There are enough parts in the full path");
+  }
+
+  runNextTest();
+});
+
+addTest(function getCssPath() {
+  let data = [{
+    selector: "#id",
+    path: "html body div div div.class div#id"
+  }, {
+    selector: "html",
+    path: "html"
+  }, {
+    selector: "body",
+    path: "html body"
+  }, {
+    selector: ".c1.c2.c3",
+    path: "html body span.c1.c2.c3"
+  }, {
+    selector: "#i",
+    path: "html body span#i.c1.c2"
+  }];
+
+  for (let {selector, path} of data) {
+    let node = document.querySelector(selector);
+    is (CssLogic.getCssPath(node), path, `Full css path is correct for ${selector}`);
+  }
+
+  runNextTest();
+});
+  </script>
+</head>
+<body>
+  <div>
+    <div>
+      <div class="class">
+        <div id="id"></div>
+      </div>
+    </div>
+  </div>
+  <span class="c1 c2 c3"></span>
+  <span id="i" class="c1 c2"></span>
+</body>
+</html>