Bug 1323700 - Adding a copy full path option to the inspector menu; r=gl
authorPatrick Brosset <pbrosset@mozilla.com>
Tue, 17 Jan 2017 14:14:41 +0100
changeset 377173 76452d510db7a4c041e0e6c3a6eb1ca4b67e3f76
parent 377172 ef886fa0b8f18cc7c8c3c7a3f4ff74f86075cc0a
child 377174 b962c583ab06b029c0fee5ae2b12c1a47338b7d8
push id1419
push userjlund@mozilla.com
push dateMon, 10 Apr 2017 20:44:07 +0000
treeherdermozilla-release@5e6801b73ef6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersgl
bugs1323700
milestone53.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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>