Bug 1359144 - use client-side source map service in console; r?jryans draft
authorTom Tromey <tom@tromey.com>
Fri, 28 Apr 2017 10:12:57 -0600
changeset 571546 f2ed181a4aedab7e5795780f3e6bc10edc0374a2
parent 571545 50217bc04457d1804fa41dd0b0a3a6ae4d46bd9f
child 626796 00507079dcf4e901373654d73860b95c14e1675b
push id56831
push userbmo:ttromey@mozilla.com
push dateTue, 02 May 2017 20:47:48 +0000
reviewersjryans
bugs1359144
milestone55.0a1
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.
    *