Bug 1359144 - use client-side source map service in console; r=jryans
authorTom Tromey <tom@tromey.com>
Fri, 28 Apr 2017 10:12:57 -0600
changeset 356117 d5a0c4a83b726ed4747855de2094e4ea50328209
parent 356116 aa42442a1a5654f4f2ddd023f15e77146999cfad
child 356118 20181e847ebe728914ae22555c6567c3258b6b1f
push id31756
push usercbook@mozilla.com
push dateWed, 03 May 2017 08:10:28 +0000
treeherdermozilla-central@604acb6a6aec [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjryans
bugs1359144
milestone55.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 1359144 - use client-side source map service in console; r=jryans MozReview-Commit-ID: Jn9fr1EoPg9
devtools/client/framework/moz.build
devtools/client/framework/source-map-url-service.js
devtools/client/framework/test/browser_source_map-01.js
devtools/client/framework/toolbox.js
devtools/client/shared/components/frame.js
devtools/client/shared/components/test/mochitest/test_frame_01.html
devtools/client/webconsole/new-console-output/new-console-output-wrapper.js
devtools/client/webconsole/webconsole.js
--- a/devtools/client/framework/moz.build
+++ b/devtools/client/framework/moz.build
@@ -21,16 +21,17 @@ DevToolsModules(
     'devtools.js',
     'gDevTools.jsm',
     'location-store.js',
     'menu-item.js',
     'menu.js',
     'selection.js',
     'sidebar.js',
     'source-map-service.js',
+    'source-map-url-service.js',
     'target-from-url.js',
     'target.js',
     'toolbox-highlighter-utils.js',
     'toolbox-host-manager.js',
     'toolbox-hosts.js',
     'toolbox-options.js',
     'toolbox.js',
     'ToolboxProcess.jsm',
new file mode 100644
--- /dev/null
+++ b/devtools/client/framework/source-map-url-service.js
@@ -0,0 +1,89 @@
+/* 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";
+
+/**
+ * A simple service to track source actors and keep a mapping between
+ * original URLs and objects holding the source actor's ID (which is
+ * used as a cookie by the devtools-source-map service) and the source
+ * map URL.
+ *
+ * @param {object} target
+ *        The object the toolbox is debugging.
+ * @param {SourceMapService} sourceMapService
+ *        The devtools-source-map functions
+ */
+function SourceMapURLService(target, sourceMapService) {
+  this._target = target;
+  this._sourceMapService = sourceMapService;
+  this._urls = new Map();
+
+  this._onSourceUpdated = this._onSourceUpdated.bind(this);
+  this.reset = this.reset.bind(this);
+
+  target.on("source-updated", this._onSourceUpdated);
+  target.on("will-navigate", this.reset);
+}
+
+/**
+ * Reset the service.  This flushes the internal cache.
+ */
+SourceMapURLService.prototype.reset = function () {
+  this._urls.clear();
+};
+
+/**
+ * Shut down the service, unregistering its event listeners and
+ * flushing the cache.  After this call the service will no longer
+ * function.
+ */
+SourceMapURLService.prototype.destroy = function () {
+  this.reset();
+  this._target.off("source-updated", this._onSourceUpdated);
+  this._target.off("will-navigate", this.reset);
+  this._target = this._urls = null;
+};
+
+/**
+ * A helper function that is called when a new source is available.
+ */
+SourceMapURLService.prototype._onSourceUpdated = function (_, sourceEvent) {
+  let { source } = sourceEvent;
+  let { generatedUrl, url, actor: id, sourceMapURL } = source;
+
+  // As long as the actor is also handling source maps, we want the
+  // generated URL if it is available.  This will be going away in bug 1349354.
+  let seenUrl = generatedUrl || url;
+  this._urls.set(seenUrl, { id, url: seenUrl, sourceMapURL });
+};
+
+/**
+ * Look up the original position for a given location.  This returns a
+ * promise resolving to either the original location, or null if the
+ * given location is not source-mapped.  If a location is returned, it
+ * is of the same form as devtools-source-map's |getOriginalLocation|.
+ *
+ * @param {String} url
+ *        The URL to map.
+ * @param {number} line
+ *        The line number to map.
+ * @param {number} column
+ *        The column number to map.
+ * @return Promise
+ *        A promise resolving either to the original location, or null.
+ */
+SourceMapURLService.prototype.originalPositionFor = async function (url, line, column) {
+  const urlInfo = this._urls.get(url);
+  if (!urlInfo) {
+    return null;
+  }
+  // Call getOriginalURLs to make sure the source map has been
+  // fetched.  We don't actually need the result of this though.
+  await this._sourceMapService.getOriginalURLs(urlInfo);
+  const location = { sourceId: urlInfo.id, line, column, sourceUrl: url };
+  let resolvedLocation = await this._sourceMapService.getOriginalLocation(location);
+  return resolvedLocation === location ? null : resolvedLocation;
+};
+
+exports.SourceMapURLService = SourceMapURLService;
--- a/devtools/client/framework/test/browser_source_map-01.js
+++ b/devtools/client/framework/test/browser_source_map-01.js
@@ -19,74 +19,55 @@ registerCleanupFunction(function* () {
   Services.prefs.clearUserPref("devtools.debugger.new-debugger-frontend");
 });
 
 const DEBUGGER_ROOT = "http://example.com/browser/devtools/client/debugger/test/mochitest/";
 // Empty page
 const PAGE_URL = `${DEBUGGER_ROOT}doc_empty-tab-01.html`;
 const JS_URL = `${URL_ROOT}code_binary_search.js`;
 const COFFEE_URL = `${URL_ROOT}code_binary_search.coffee`;
-const { SourceMapService } = require("devtools/client/framework/source-map-service");
-const { serialize } = require("devtools/client/framework/location-store");
 
 add_task(function* () {
   const toolbox = yield openNewTabAndToolbox(PAGE_URL, "jsdebugger");
-  const service = new SourceMapService(toolbox.target);
-  let aggregator = new Map();
-
-  function onUpdate(e, oldLoc, newLoc) {
-    if (oldLoc.line === 6) {
-      checkLoc1(oldLoc, newLoc);
-    } else if (oldLoc.line === 8) {
-      checkLoc2(oldLoc, newLoc);
-    } else {
-      throw new Error(`Unexpected location update: ${JSON.stringify(oldLoc)}`);
-    }
-    aggregator.set(serialize(oldLoc), newLoc);
-  }
-
-  let loc1 = { url: JS_URL, line: 6 };
-  let loc2 = { url: JS_URL, line: 8, column: 3 };
-
-  service.subscribe(loc1, onUpdate);
-  service.subscribe(loc2, onUpdate);
+  const service = toolbox.sourceMapURLService;
 
   // Inject JS script
   let sourceShown = waitForSourceShown(toolbox.getCurrentPanel(), "code_binary_search");
   yield createScript(JS_URL);
   yield sourceShown;
 
-  yield waitUntil(() => aggregator.size === 2);
+  let loc1 = { url: JS_URL, line: 6 };
+  let newLoc1 = yield service.originalPositionFor(loc1.url, loc1.line);
+  checkLoc1(loc1, newLoc1);
 
-  aggregator = Array.from(aggregator.values());
-
-  ok(aggregator.find(i => i.url === COFFEE_URL && i.line === 4), "found first updated location");
-  ok(aggregator.find(i => i.url === COFFEE_URL && i.line === 6), "found second updated location");
+  let loc2 = { url: JS_URL, line: 8, column: 3 };
+  let newLoc2 = yield service.originalPositionFor(loc2.url, loc2.line, loc2.column);
+  checkLoc2(loc2, newLoc2);
 
   yield toolbox.destroy();
   gBrowser.removeCurrentTab();
   finish();
 });
 
 function checkLoc1(oldLoc, newLoc) {
   is(oldLoc.line, 6, "Correct line for JS:6");
   is(oldLoc.column, null, "Correct column for JS:6");
   is(oldLoc.url, JS_URL, "Correct url for JS:6");
   is(newLoc.line, 4, "Correct line for JS:6 -> COFFEE");
   is(newLoc.column, 2, "Correct column for JS:6 -> COFFEE -- handles falsy column entries");
-  is(newLoc.url, COFFEE_URL, "Correct url for JS:6 -> COFFEE");
+  is(newLoc.sourceUrl, COFFEE_URL, "Correct url for JS:6 -> COFFEE");
 }
 
 function checkLoc2(oldLoc, newLoc) {
   is(oldLoc.line, 8, "Correct line for JS:8:3");
   is(oldLoc.column, 3, "Correct column for JS:8:3");
   is(oldLoc.url, JS_URL, "Correct url for JS:8:3");
   is(newLoc.line, 6, "Correct line for JS:8:3 -> COFFEE");
   is(newLoc.column, 10, "Correct column for JS:8:3 -> COFFEE");
-  is(newLoc.url, COFFEE_URL, "Correct url for JS:8:3 -> COFFEE");
+  is(newLoc.sourceUrl, COFFEE_URL, "Correct url for JS:8:3 -> COFFEE");
 }
 
 function createScript(url) {
   info(`Creating script: ${url}`);
   let mm = getFrameScript();
   let command = `
     let script = document.createElement("script");
     script.setAttribute("src", "${url}");
--- a/devtools/client/framework/toolbox.js
+++ b/devtools/client/framework/toolbox.js
@@ -57,16 +57,18 @@ loader.lazyRequireGetter(this, "KeyShort
 loader.lazyRequireGetter(this, "ZoomKeys",
   "devtools/client/shared/zoom-keys");
 loader.lazyRequireGetter(this, "settleAll",
   "devtools/shared/ThreadSafeDevToolsUtils", true);
 loader.lazyRequireGetter(this, "ToolboxButtons",
   "devtools/client/definitions", true);
 loader.lazyRequireGetter(this, "SourceMapService",
   "devtools/client/framework/source-map-service", true);
+loader.lazyRequireGetter(this, "SourceMapURLService",
+  "devtools/client/framework/source-map-url-service", true);
 loader.lazyRequireGetter(this, "HUDService",
   "devtools/client/webconsole/hudservice");
 loader.lazyRequireGetter(this, "viewSource",
   "devtools/client/shared/view-source");
 
 loader.lazyGetter(this, "registerHarOverlay", () => {
   return require("devtools/client/netmonitor/src/har/toolbox-overlay").register;
 });
@@ -531,32 +533,52 @@ Toolbox.prototype = {
   },
 
   get ToolboxController() {
     return this.browserRequire("devtools/client/framework/components/toolbox-controller");
   },
 
   /**
    * A common access point for the client-side mapping service for source maps that
-   * any panel can use.
+   * any panel can use.  This is a "low-level" API that connects to
+   * the source map worker.
    */
   get sourceMapService() {
     if (!Services.prefs.getBoolPref("devtools.source-map.client-service.enabled")) {
       return null;
     }
     if (this._sourceMapService) {
       return this._sourceMapService;
     }
     // Uses browser loader to access the `Worker` global.
     this._sourceMapService =
       this.browserRequire("devtools/client/shared/source-map/index");
     this._sourceMapService.startSourceMapWorker(SOURCE_MAP_WORKER);
     return this._sourceMapService;
   },
 
+  /**
+   * Clients wishing to use source maps but that want the toolbox to
+   * track the source actor mapping can use this source map service.
+   * This is a higher-level service than the one returned by
+   * |sourceMapService|, in that it automatically tracks source actor
+   * IDs.
+   */
+  get sourceMapURLService() {
+    if (this._sourceMapURLService) {
+      return this._sourceMapURLService;
+    }
+    let sourceMaps = this.sourceMapService;
+    if (!sourceMaps) {
+      return null;
+    }
+    this._sourceMapURLService = new SourceMapURLService(this._target, sourceMaps);
+    return this._sourceMapURLService;
+  },
+
   // Return HostType id for telemetry
   _getTelemetryHostId: function () {
     switch (this.hostType) {
       case Toolbox.HostType.BOTTOM: return 0;
       case Toolbox.HostType.SIDE: return 1;
       case Toolbox.HostType.WINDOW: return 2;
       case Toolbox.HostType.CUSTOM: return 3;
       default: return 9;
@@ -2294,16 +2316,21 @@ Toolbox.prototype = {
 
     this._lastFocusedElement = null;
 
     if (this._deprecatedServerSourceMapService) {
       this._deprecatedServerSourceMapService.destroy();
       this._deprecatedServerSourceMapService = null;
     }
 
+    if (this._sourceMapURLService) {
+      this._sourceMapURLService.destroy();
+      this._sourceMapURLService = null;
+    }
+
     if (this._sourceMapService) {
       this._sourceMapService.stopSourceMapWorker();
       this._sourceMapService = null;
     }
 
     if (this.webconsolePanel) {
       this._saveSplitConsoleHeight();
       this.webconsolePanel.removeEventListener("resize",
--- a/devtools/client/shared/components/frame.js
+++ b/devtools/client/shared/components/frame.js
@@ -47,70 +47,57 @@ module.exports = createClass({
       showEmptyPathAsHost: false,
       showFullSourceUrl: false,
     };
   },
 
   componentWillMount() {
     const sourceMapService = this.props.sourceMapService;
     if (sourceMapService) {
-      const source = this.getSource();
-      sourceMapService.subscribe(source, this.onSourceUpdated);
-    }
-  },
-
-  componentWillUnmount() {
-    const sourceMapService = this.props.sourceMapService;
-    if (sourceMapService) {
-      const source = this.getSource();
-      sourceMapService.unsubscribe(source, this.onSourceUpdated);
+      const { source, line, column } = this.props.frame;
+      sourceMapService.originalPositionFor(source, line, column)
+        .then(resolvedLocation => {
+          if (resolvedLocation) {
+            this.onSourceUpdated(resolvedLocation);
+          }
+        });
     }
   },
 
   /**
    * Component method to update the FrameView when a resolved location is available
-   * @param event
-   * @param location
+   * @param {Location} resolvedLocation
+   *        the resolved location as found via a source map
    */
-  onSourceUpdated(event, location, resolvedLocation) {
-    const frame = this.getFrame(resolvedLocation);
+  onSourceUpdated(resolvedLocation) {
+    const { sourceUrl, line, column } = resolvedLocation;
+    const frame = {
+      source: sourceUrl,
+      line,
+      column,
+      functionDisplayName: this.props.frame.functionDisplayName,
+    };
     this.setState({
       frame,
       isSourceMapped: true,
     });
   },
 
   /**
-   * Utility method to convert the Frame object to the
-   * Source Object model required by SourceMapService
-   * @param frame
-   * @returns {{url: *, line: *, column: *}}
+   * Utility method to convert the Frame object model to the
+   * object model required by the onClick callback.
+   * @param Frame frame
+   * @returns {{url: *, line: *, column: *, functionDisplayName: *}}
    */
-  getSource(frame) {
-    frame = frame || this.props.frame;
+  getSourceForClick(frame) {
     const { source, line, column } = frame;
     return {
       url: source,
       line,
       column,
-    };
-  },
-
-  /**
-   * Utility method to convert the Source object model to the
-   * Frame object model required by FrameView class.
-   * @param source
-   * @returns {{source: *, line: *, column: *, functionDisplayName: *}}
-   */
-  getFrame(source) {
-    const { url, line, column } = source;
-    return {
-      source: url,
-      line,
-      column,
       functionDisplayName: this.props.frame.functionDisplayName,
     };
   },
 
   render() {
     let frame, isSourceMapped;
     let {
       onClick,
@@ -219,17 +206,17 @@ module.exports = createClass({
     }, sourceElements);
 
     // If source is not a URL (self-hosted, eval, etc.), don't make
     // it an anchor link, as we can't link to it.
     if (isLinkable) {
       sourceEl = dom.a({
         onClick: e => {
           e.preventDefault();
-          onClick(this.getSource(frame));
+          onClick(this.getSourceForClick(frame));
         },
         href: source,
         className: "frame-link-source",
         draggable: false,
       }, sourceInnerEl);
     } else {
       sourceEl = dom.span({
         key: "source",
--- a/devtools/client/shared/components/test/mochitest/test_frame_01.html
+++ b/devtools/client/shared/components/test/mochitest/test_frame_01.html
@@ -282,22 +282,53 @@ window.onload = Task.async(function* () 
       showEmptyPathAsHost: true,
     }, {
       file: "www.cnn.com",
       line: "1",
       shouldLink: true,
       tooltip: "View source in Debugger → http://www.cnn.com/:1",
     });
 
+    const resolvedLocation = {
+      sourceId: "whatever",
+      line: 23,
+      sourceUrl: "https://bugzilla.mozilla.org/original.js",
+    };
+    let mockSourceMapService = {
+      originalPositionFor: function () {
+	// Return a phony promise-like thing that resolves
+	// immediately.
+	return {
+	  then: function (consequence) {
+	    consequence(resolvedLocation);
+	  },
+	};
+      },
+    };
+    yield checkFrameComponent({
+      frame: {
+	line: 97,
+	source: "https://bugzilla.mozilla.org/bundle.js",
+      },
+      sourceMapService: mockSourceMapService,
+    }, {
+      file: "original.js",
+      line: resolvedLocation.line,
+      shouldLink: true,
+      tooltip: "View source in Debugger → https://bugzilla.mozilla.org/original.js:23",
+      source: "https://bugzilla.mozilla.org/original.js",
+    });
+
     function* checkFrameComponent(input, expected) {
       let props = Object.assign({ onClick: () => {} }, input);
       let frame = ReactDOM.render(Frame(props), window.document.body);
       let el = ReactDOM.findDOMNode(frame);
       let { source } = input.frame;
       checkFrameString(Object.assign({ el, source }, expected));
+      ReactDOM.unmountComponentAtNode(window.document.body);
     }
 
   } catch (e) {
     ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e));
   } finally {
     SimpleTest.finish();
   }
 });
--- a/devtools/client/webconsole/new-console-output/new-console-output-wrapper.js
+++ b/devtools/client/webconsole/new-console-output/new-console-output-wrapper.js
@@ -87,18 +87,17 @@ NewConsoleOutputWrapper.prototype = {
           frame.url,
           frame.line
         ),
         openNetworkPanel: (requestId) => {
           return this.toolbox.selectTool("netmonitor").then(panel => {
             return panel.panelWin.NetMonitorController.inspectRequest(requestId);
           });
         },
-        sourceMapService:
-          this.toolbox ? this.toolbox._deprecatedServerSourceMapService : null,
+        sourceMapService: this.toolbox ? this.toolbox.sourceMapURLService : null,
         highlightDomElement: (grip, options = {}) => {
           return this.toolbox.highlighterUtils
             ? this.toolbox.highlighterUtils.highlightDomValueGrip(grip, options)
             : null;
         },
         unHighlightDomElement: (forceHide = false) => {
           return this.toolbox.highlighterUtils
             ? this.toolbox.highlighterUtils.unhighlight(forceHide)
--- a/devtools/client/webconsole/webconsole.js
+++ b/devtools/client/webconsole/webconsole.js
@@ -2621,17 +2621,17 @@ WebConsoleFrame.prototype = {
 
     let { url, line, column } = location;
     let source = url ? url.split(" -> ").pop() : "";
 
     this.ReactDOM.render(this.FrameView({
       frame: { source, line, column },
       showEmptyPathAsHost: true,
       onClick,
-      sourceMapService: toolbox ? toolbox._deprecatedServerSourceMapService : null,
+      sourceMapService: toolbox ? toolbox.sourceMapURLService : null,
     }), locationNode);
 
     return locationNode;
   },
 
   /**
    * Adjusts the category and severity of the given message.
    *