Bug 1005909 - Make URLs in console strings clickable. r=rcampbell
authorConnor Brem <cbrem@mozilla.com>
Thu, 05 Jun 2014 16:12:00 -0400
changeset 207493 59e2e6c3c1ec4e5d3ee1d546aaa3f41c9180def6
parent 207492 09d3ce10e11087d5088e033814f331e4f265d7ce
child 207494 1023a50167c5957c5924f9881703fe36ce1c6fef
push id494
push userraliiev@mozilla.com
push dateMon, 25 Aug 2014 18:42:16 +0000
treeherdermozilla-release@a3cc3e46b571 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrcampbell
bugs1005909
milestone32.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 1005909 - Make URLs in console strings clickable. r=rcampbell
browser/devtools/webconsole/console-output.js
browser/devtools/webconsole/test/browser.ini
browser/devtools/webconsole/test/browser_webconsole_clickable_urls.js
browser/devtools/webconsole/test/browser_webconsole_output_03.js
browser/devtools/webconsole/test/browser_webconsole_output_04.js
browser/devtools/webconsole/test/head.js
--- a/browser/devtools/webconsole/console-output.js
+++ b/browser/devtools/webconsole/console-output.js
@@ -8,16 +8,17 @@
 const {Cc, Ci, Cu} = require("chrome");
 
 loader.lazyImporter(this, "VariablesView", "resource:///modules/devtools/VariablesView.jsm");
 loader.lazyImporter(this, "escapeHTML", "resource:///modules/devtools/VariablesView.jsm");
 loader.lazyImporter(this, "gDevTools", "resource:///modules/devtools/gDevTools.jsm");
 loader.lazyImporter(this, "Task","resource://gre/modules/Task.jsm");
 
 const Heritage = require("sdk/core/heritage");
+const URI = Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService);
 const XHTML_NS = "http://www.w3.org/1999/xhtml";
 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
 const STRINGS_URI = "chrome://browser/locale/devtools/webconsole.properties";
 
 const WebConsoleUtils = require("devtools/toolkit/webconsole/utils").Utils;
 const l10n = new WebConsoleUtils.l10n(STRINGS_URI);
 
 // Constants for compatibility with the Web Console output implementation before
@@ -1076,16 +1077,21 @@ Messages.Extended.prototype = Heritage.e
       if (grip.type == "longString") {
         let widget = new Widgets.LongString(this, grip, options).render();
         return widget.element;
       }
     }
 
     let result = this.document.createElementNS(XHTML_NS, "span");
     if (isPrimitive) {
+      if (Widgets.URLString.prototype.containsURL.call(Widgets.URLString.prototype, grip)) {
+        let widget = new Widgets.URLString(this, grip, options).render();
+        return widget.element;
+      }
+
       let className = this.getClassNameForValueGrip(grip);
       if (className) {
         result.className = className;
       }
 
       result.textContent = VariablesView.getString(grip, {
         noStringQuotes: noStringQuotes,
         concise: options.concise,
@@ -1753,16 +1759,135 @@ Widgets.MessageTimestamp.prototype = Her
     this.element.textContent = l10n.timestampString(this.timestamp) + " ";
 
     return this;
   },
 }); // Widgets.MessageTimestamp.prototype
 
 
 /**
+ * The URLString widget, for rendering strings where at least one token is a
+ * URL.
+ *
+ * @constructor
+ * @param object message
+ *        The owning message.
+ * @param string str
+ *        The string, which contains at least one valid URL.
+ */
+Widgets.URLString = function(message, str)
+{
+  Widgets.BaseWidget.call(this, message);
+  this.str = str;
+};
+
+Widgets.URLString.prototype = Heritage.extend(Widgets.BaseWidget.prototype,
+{
+  /**
+   * The string to format, which contains at least one valid URL.
+   * @type string
+   */
+  str: "",
+
+  render: function()
+  {
+    if (this.element) {
+      return this;
+    }
+
+    // The rendered URLString will be a <span> containing a number of text
+    // <spans> for non-URL tokens and <a>'s for URL tokens.
+    this.element = this.el("span", {
+      class: "console-string"
+    });
+    this.element.appendChild(this._renderText("\""));
+
+    // As we walk through the tokens of the source string, we make sure to preserve
+    // the original whitespace that seperated the tokens.
+    let tokens = this.str.split(/\s+/);
+    let textStart = 0;
+    let tokenStart;
+    for (let token of tokens) {
+      tokenStart = this.str.indexOf(token, textStart);
+      if (this._isURL(token)) {
+        this.element.appendChild(this._renderText(this.str.slice(textStart, tokenStart)));
+        textStart = tokenStart + token.length;
+        this.element.appendChild(this._renderURL(token));
+      }
+    }
+
+    // Clean up any non-URL text at the end of the source string.
+    this.element.appendChild(this._renderText(this.str.slice(textStart, this.str.length)));
+    this.element.appendChild(this._renderText("\""));
+
+    return this;
+  },
+
+  /**
+   * Determines whether a grip is a string containing a URL.
+   *
+   * @param string grip
+   *        The grip, which may contain a URL.
+   * @return boolean
+   *         Whether the grip is a string containing a URL.
+   */
+  containsURL: function(grip)
+  {
+    if (typeof grip != "string") {
+      return false;
+    }
+
+    let tokens = grip.split(/\s+/);
+    return tokens.some(this._isURL);
+  },
+
+  /**
+   * Determines whether a string token is a valid URL.
+   *
+   * @param string token
+   *        The token.
+   * @return boolean
+   *         Whenther the token is a URL.
+   */
+  _isURL: function(token) {
+    try {
+      let uri = URI.newURI(token, null, null);
+      let url = uri.QueryInterface(Ci.nsIURL);
+      return true;
+    } catch (e) {
+      return false;
+    }
+  },
+
+  /**
+   * Renders a string as a URL.
+   *
+   * @param string url
+   *        The string to be rendered as a url.
+   * @return DOMElement
+   *         An element containing the rendered string.
+   */
+  _renderURL: function(url)
+  {
+    let result = this.el("a", {
+      class: "url",
+      title: url,
+      href: url,
+      draggable: false
+    }, url);
+    this.message._addLinkCallback(result);
+    return result;
+  },
+
+  _renderText: function(text) {
+    return this.el("span", text);
+  },
+}); // Widgets.URLString.prototype
+
+/**
  * Widget used for displaying ObjectActors that have no specialised renderers.
  *
  * @constructor
  * @param object message
  *        The owning message.
  * @param object objectActor
  *        The ObjectActor to display.
  * @param object [options]
--- a/browser/devtools/webconsole/test/browser.ini
+++ b/browser/devtools/webconsole/test/browser.ini
@@ -240,16 +240,17 @@ run-if = os == "mac"
 [browser_webconsole_bug_837351_securityerrors.js]
 [browser_webconsole_bug_846918_hsts_invalid-headers.js]
 [browser_webconsole_bug_915141_toggle_response_logging_with_keyboard.js]
 [browser_webconsole_bug_1006027_message_timestamps_incorrect.js]
 [browser_webconsole_bug_1010953_cspro.js]
 [browser_webconsole_cached_autocomplete.js]
 [browser_webconsole_change_font_size.js]
 [browser_webconsole_chrome.js]
+[browser_webconsole_clickable_urls.js]
 [browser_webconsole_closure_inspection.js]
 [browser_webconsole_completion.js]
 [browser_webconsole_console_extras.js]
 [browser_webconsole_console_logging_api.js]
 [browser_webconsole_count.js]
 [browser_webconsole_dont_navigate_on_doubleclick.js]
 [browser_webconsole_execution_scope.js]
 [browser_webconsole_for_of.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webconsole/test/browser_webconsole_clickable_urls.js
@@ -0,0 +1,83 @@
+/*
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// When strings containing URLs are entered into the webconsole,
+// check its output and ensure that the output can be clicked to open those URLs.
+
+const TEST_URI = "data:text/html;charset=utf8,Bug 1005909 - Clickable URLS";
+
+let inputTests = [
+
+  // 0: URL opens page when clicked.
+  {
+    input: "'http://example.com'",
+    output: "http://example.com",
+    expectedTab: "http://example.com/",
+  },
+
+  // 1: URL opens page using https when clicked.
+  {
+    input: "'https://example.com'",
+    output: "https://example.com",
+    expectedTab: "https://example.com/",
+  },
+
+  // 2: URL with port opens page when clicked.
+  {
+    input: "'https://example.com:443'",
+    output: "https://example.com:443",
+    expectedTab: "https://example.com/",
+  },
+
+  // 3: URL containing non-empty path opens page when clicked.
+  {
+    input: "'http://example.com/foo'",
+    output: "http://example.com/foo",
+    expectedTab: "http://example.com/foo",
+  },
+
+  // 4: URL opens page when clicked, even when surrounded by non-URL tokens.
+  {
+  	input: "'foo http://example.com bar'",
+  	output: "foo http://example.com bar",
+  	expectedTab: "http://example.com/",
+  },
+
+  // 5: URL opens page when clicked, and whitespace is be preserved.
+  {
+  	input: "'foo\\nhttp://example.com\\nbar'",
+  	output: "foo\nhttp://example.com\nbar",
+  	expectedTab: "http://example.com/",
+  },
+
+  // 6: URL opens page when clicked when multiple links are present.
+  {
+  	input: "'http://example.com http://example.com'",
+  	output: "http://example.com http://example.com",
+  	expectedTab: "http://example.com/",
+  },
+
+  // 7: URL without scheme does not open page when clicked.
+  {
+    input: "'example.com'",
+    output: "example.com",
+  },
+
+  // 8: URL with invalid scheme does not open page when clicked.
+  {
+    input: "'foo://example.com'",
+    output: "foo://example.com",
+  },
+
+];
+
+function test() {
+  Task.spawn(function*() {
+    let {tab} = yield loadTab(TEST_URI);
+    let hud = yield openConsole(tab);
+    yield checkOutputForInputs(hud, inputTests);
+    inputTests = null;
+  }).then(finishTest);
+}
--- a/browser/devtools/webconsole/test/browser_webconsole_output_03.js
+++ b/browser/devtools/webconsole/test/browser_webconsole_output_03.js
@@ -3,16 +3,17 @@
  * http://creativecommons.org/publicdomain/zero/1.0/
  */
 
 // Test the webconsole output for various types of objects.
 
 const TEST_URI = "http://example.com/browser/browser/devtools/webconsole/test/test-console-output-03.html";
 
 let inputTests = [
+
   // 0
   {
     input: "document",
     output: "HTMLDocument \u2192 " + TEST_URI,
     printOutput: "[object HTMLDocument]",
     inspectable: true,
     noClick: true,
   },
@@ -52,16 +53,17 @@ let inputTests = [
     inspectable: true,
     variablesViewLabel: "DOMTokenList[0]",
   },
 
   // 5
   {
     input: "window.location.href",
     output: '"' + TEST_URI + '"',
+    noClick: true,
   },
 
   // 6
   {
     input: "window.location",
     output: "Location \u2192 " + TEST_URI,
     printOutput: TEST_URI,
     inspectable: true,
--- a/browser/devtools/webconsole/test/browser_webconsole_output_04.js
+++ b/browser/devtools/webconsole/test/browser_webconsole_output_04.js
@@ -105,16 +105,15 @@ let inputTests = [
     output: 'CSSMediaRule "print"',
     printOutput: "[object CSSMediaRule",
     inspectable: true,
     variablesViewLabel: "CSSMediaRule",
   },
 ];
 
 function test() {
-  addTab(TEST_URI);
-  browser.addEventListener("load", function onLoad() {
-    browser.removeEventListener("load", onLoad, true);
-    openConsole().then((hud) => {
-      return checkOutputForInputs(hud, inputTests);
-    }).then(finishTest);
-  }, true);
+  Task.spawn(function*() {
+    const {tab} = yield loadTab(TEST_URI);
+    const hud = yield openConsole(tab);
+    yield checkOutputForInputs(hud, inputTests);
+    inputTests = null;
+  }).then(finishTest);
 }
--- a/browser/devtools/webconsole/test/head.js
+++ b/browser/devtools/webconsole/test/head.js
@@ -79,16 +79,42 @@ function loadTab(url) {
   browser.addEventListener("load", function onLoad() {
     browser.removeEventListener("load", onLoad, true);
     deferred.resolve({tab: tab, browser: browser});
   }, true);
 
   return deferred.promise;
 }
 
+function loadBrowser(browser) {
+  let deferred = promise.defer();
+
+  browser.addEventListener("load", function onLoad() {
+    browser.removeEventListener("load", onLoad, true);
+    deferred.resolve(null)
+  }, true);
+
+  return deferred.promise;
+}
+
+function closeTab(tab) {
+  let deferred = promise.defer();
+
+  let container = gBrowser.tabContainer;
+
+  container.addEventListener("TabClose", function onTabClose() {
+    container.removeEventListener("TabClose", onTabClose, true);
+    deferred.resolve(null);
+  }, true);
+
+  gBrowser.removeTab(tab);
+
+  return deferred.promise;
+}
+
 function afterAllTabsLoaded(callback, win) {
   win = win || window;
 
   let stillToLoad = 0;
 
   function onLoad() {
     this.removeEventListener("load", onLoad, true);
     stillToLoad--;
@@ -1377,31 +1403,32 @@ function whenDelayedStartupFinished(aWin
  *
  *        - variablesViewLabel: string|RegExp, optional, the expected variables
  *        view label when the object is inspected. If this is not provided, then
  *        |output| is used.
  *
  *        - inspectorIcon: boolean, when true, the test runner expects the
  *        result widget to contain an inspectorIcon element (className
  *        open-inspector).
+ *
+ *        - expectedTab: string, optional, the full URL of the new tab which must
+ *        open. If this is not provided, any new tabs that open will cause a test
+ *        failure.
  */
 function checkOutputForInputs(hud, inputTests)
 {
-  let eventHandlers = new Set();
+  let container = gBrowser.tabContainer;
 
   function* runner()
   {
     for (let [i, entry] of inputTests.entries()) {
       info("checkInput(" + i + "): " + entry.input);
       yield checkInput(entry);
     }
-
-    for (let fn of eventHandlers) {
-      hud.jsterm.off("variablesview-open", fn);
-    }
+    container = null;
   }
 
   function* checkInput(entry)
   {
     yield checkConsoleLog(entry);
     yield checkPrintOutput(entry);
     yield checkJSEval(entry);
   }
@@ -1462,37 +1489,49 @@ function checkOutputForInputs(hud, input
     if (!entry.noClick) {
       yield checkObjectClick(entry, msg);
     }
     if (typeof entry.inspectorIcon == "boolean") {
       yield checkLinkToInspector(entry, msg);
     }
   }
 
-  function checkObjectClick(entry, msg)
+  function* checkObjectClick(entry, msg)
   {
     let body = msg.querySelector(".message-body a") ||
                msg.querySelector(".message-body");
     ok(body, "the message body");
 
-    let deferred = promise.defer();
+    let deferredVariablesView = promise.defer();
+    entry._onVariablesViewOpen = onVariablesViewOpen.bind(null, entry, deferredVariablesView);
+    hud.jsterm.on("variablesview-open", entry._onVariablesViewOpen);
 
-    entry._onVariablesViewOpen = onVariablesViewOpen.bind(null, entry, deferred);
-    hud.jsterm.on("variablesview-open", entry._onVariablesViewOpen);
-    eventHandlers.add(entry._onVariablesViewOpen);
+    let deferredTab = promise.defer();
+    entry._onTabOpen = onTabOpen.bind(null, entry, deferredTab);
+    container.addEventListener("TabOpen", entry._onTabOpen, true);
 
     body.scrollIntoView();
     EventUtils.synthesizeMouse(body, 2, 2, {}, hud.iframeWindow);
 
     if (entry.inspectable) {
       info("message body tagName '" + body.tagName +  "' className '" + body.className + "'");
-      return deferred.promise; // wait for the panel to open if we need to.
+      yield deferredVariablesView.promise;
+    } else {
+      hud.jsterm.off("variablesview-open", entry._onVariablesView);
+      entry._onVariablesView = null;
     }
 
-    return promise.resolve(null);
+    if (entry.expectedTab) {
+      yield deferredTab.promise;
+    } else {
+      container.removeEventListener("TabOpen", entry._onTabOpen, true);
+      entry._onTabOpen = null;
+    }
+
+    yield promise.resolve(null);
   }
 
   function checkLinkToInspector(entry, msg)
   {
     let elementNodeWidget = [...msg._messageObject.widgets][0];
     if (!elementNodeWidget) {
       ok(!entry.inspectorIcon, "The message has no ElementNode widget");
       return;
@@ -1508,29 +1547,42 @@ function checkOutputForInputs(hud, input
           "The ElementNode widget isn't linked to the inspector");
       }
     }, () => {
       // linkToInspector promise rejected, node not linked to inspector
       ok(!entry.inspectorIcon, "The ElementNode widget isn't linked to the inspector");
     });
   }
 
-  function onVariablesViewOpen(entry, deferred, event, view, options)
+  function onVariablesViewOpen(entry, {resolve, reject}, event, view, options)
   {
     let label = entry.variablesViewLabel || entry.output;
     if (typeof label == "string" && options.label != label) {
       return;
     }
     if (label instanceof RegExp && !label.test(options.label)) {
       return;
     }
 
     hud.jsterm.off("variablesview-open", entry._onVariablesViewOpen);
-    eventHandlers.delete(entry._onVariablesViewOpen);
     entry._onVariablesViewOpen = null;
-
     ok(entry.inspectable, "variables view was shown");
 
-    deferred.resolve(null);
+    resolve(null);
+  }
+
+  function onTabOpen(entry, {resolve, reject}, event)
+  {
+    container.removeEventListener("TabOpen", entry._onTabOpen, true);
+    entry._onTabOpen = null;
+
+    let tab = event.target;
+    let browser = gBrowser.getBrowserForTab(tab);
+    loadBrowser(browser).then(() => {
+      let uri = content.location.href;
+      ok(entry.expectedTab && entry.expectedTab == uri,
+        "opened tab '" + uri +  "', expected tab '" + entry.expectedTab + "'");
+      return closeTab(tab);
+    }).then(resolve, reject);
   }
 
   return Task.spawn(runner);
 }