Bug 1171256 - Add an API similar to Chrome's webNavigation (r=Mossop)
authorBill McCloskey <billm@mozilla.com>
Wed, 03 Jun 2015 14:53:12 -0700
changeset 267955 7eca38a74be8389935720bd096d6c23a01268a21
parent 267954 f8f4a6c0654967a5514f78e47480356bd642ec8b
child 267956 fe6e29cd1894d960b223a4291ffb992372c3b467
push id4932
push userjlund@mozilla.com
push dateMon, 10 Aug 2015 18:23:06 +0000
treeherdermozilla-esr52@6dd5a4f5f745 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMossop
bugs1171256
milestone41.0a1
Bug 1171256 - Add an API similar to Chrome's webNavigation (r=Mossop)
toolkit/modules/addons/WebNavigation.jsm
toolkit/modules/addons/WebNavigationContent.js
toolkit/modules/moz.build
toolkit/modules/tests/browser/browser.ini
toolkit/modules/tests/browser/browser_WebNavigation.js
toolkit/modules/tests/browser/file_WebNavigation_page1.html
toolkit/modules/tests/browser/file_WebNavigation_page2.html
toolkit/modules/tests/browser/file_WebNavigation_page3.html
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/addons/WebNavigation.jsm
@@ -0,0 +1,157 @@
+/* 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";
+
+const EXPORTED_SYMBOLS = ["WebNavigation"];
+
+const Ci = Components.interfaces;
+const Cc = Components.classes;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+// TODO:
+// Transition types and qualifiers
+// onReferenceFragmentUpdated also triggers for pushState
+// getFrames, getAllFrames
+// onCreatedNavigationTarget, onHistoryStateUpdated
+
+let Manager = {
+  listeners: new Map(),
+
+  init() {
+    Services.mm.addMessageListener("Extension:DOMContentLoaded", this);
+    Services.mm.addMessageListener("Extension:StateChange", this);
+    Services.mm.addMessageListener("Extension:LocationChange", this);
+    Services.mm.loadFrameScript("resource://gre/modules/WebNavigationContent.js", true);
+  },
+
+  uninit() {
+    Services.mm.removeMessageListener("Extension:StateChange", this);
+    Services.mm.removeMessageListener("Extension:LocationChange", this);
+    Services.mm.removeMessageListener("Extension:DOMContentLoaded", this);
+    Services.mm.removeDelayedFrameScript("resource://gre/modules/WebNavigationContent.js");
+    Services.mm.broadcastAsyncMessage("Extension:DisableWebNavigation");
+  },
+
+  addListener(type, listener) {
+    if (this.listeners.size == 0) {
+      this.init();
+    }
+
+    if (!this.listeners.has(type)) {
+      this.listeners.set(type, new Set());
+    }
+    let listeners = this.listeners.get(type);
+    listeners.add(listener);
+  },
+
+  removeListener(type, listener) {
+    let listeners = this.listeners.get(type);
+    if (!listeners) {
+      return;
+    }
+    listeners.delete(listener);
+    if (listeners.size == 0) {
+      this.listeners.delete(type);
+    }
+
+    if (this.listeners.size == 0) {
+      this.uninit();
+    }
+  },
+
+  receiveMessage({name, data, target}) {
+    switch (name) {
+    case "Extension:StateChange":
+      this.onStateChange(target, data);
+      break;
+
+    case "Extension:LocationChange":
+      this.onLocationChange(target, data);
+      break;
+
+    case "Extension:DOMContentLoaded":
+      this.onLoad(target, data);
+      break;
+    }
+  },
+
+  onStateChange(browser, data) {
+    let stateFlags = data.stateFlags;
+    if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) {
+      let url = data.requestURL;
+      if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
+        this.fire("onBeforeNavigate", browser, data, {url});
+      } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+        if (Components.isSuccessCode(data.status)) {
+          this.fire("onCompleted", browser, data, {url});
+        } else {
+          let error = `Error code ${data.status}`;
+          this.fire("onErrorOccurred", browser, data, {error, url});
+        }
+      }
+    }
+  },
+
+  onLocationChange(browser, data) {
+    let url = data.location;
+    if (data.flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) {
+      this.fire("onReferenceFragmentUpdated", browser, data, {url});
+    } else {
+      this.fire("onCommitted", browser, data, {url});
+    }
+  },
+
+  onLoad(browser, data) {
+    this.fire("onDOMContentLoaded", browser, data, {url: data.url});
+  },
+
+  fire(type, browser, data, extra) {
+    let listeners = this.listeners.get(type);
+    if (!listeners) {
+      return;
+    }
+
+    let details = {
+      browser,
+      windowId: data.windowId,
+    };
+
+    if (data.parentWindowId) {
+      details.parentWindowId = data.parentWindowId;
+    }
+
+    for (let prop in extra) {
+      details[prop] = extra[prop];
+    }
+
+    for (let listener of listeners) {
+      listener(details);
+    }
+  },
+};
+
+const EVENTS = [
+  "onBeforeNavigate",
+  "onCommitted",
+  "onDOMContentLoaded",
+  "onCompleted",
+  "onErrorOccurred",
+  "onReferenceFragmentUpdated",
+
+  //"onCreatedNavigationTarget",
+  //"onHistoryStateUpdated",
+];
+
+let WebNavigation = {};
+
+for (let event of EVENTS) {
+  WebNavigation[event] = {
+    addListener: Manager.addListener.bind(Manager, event),
+    removeListener: Manager.removeListener.bind(Manager, event),
+  }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/addons/WebNavigationContent.js
@@ -0,0 +1,105 @@
+const Ci = Components.interfaces;
+
+function getWindowId(window)
+{
+  return window.QueryInterface(Ci.nsIInterfaceRequestor)
+               .getInterface(Ci.nsIDOMWindowUtils)
+               .outerWindowID;
+}
+
+function getParentWindowId(window)
+{
+  return getWindowId(window.parent);
+}
+
+function loadListener(event)
+{
+  let document = event.target;
+  let window = document.defaultView;
+  let url = document.documentURI;
+  let windowId = getWindowId(window);
+  let parentWindowId = getParentWindowId(window);
+  sendAsyncMessage("Extension:DOMContentLoaded", {windowId, parentWindowId, url});
+}
+
+addEventListener("DOMContentLoaded", loadListener);
+addMessageListener("Extension:DisableWebNavigation", () => {
+  removeEventListener("DOMContentLoaded", loadListener);
+});
+
+let WebProgressListener = {
+  init: function() {
+    let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
+                              .getInterface(Ci.nsIWebProgress);
+    webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW |
+                                          Ci.nsIWebProgress.NOTIFY_LOCATION);
+  },
+
+  uninit() {
+    if (!docShell) {
+      return;
+    }
+    let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
+                              .getInterface(Ci.nsIWebProgress);
+    webProgress.removeProgressListener(this);
+  },
+
+  onStateChange: function onStateChange(webProgress, request, stateFlags, status) {
+    let data = {
+      requestURL: request.QueryInterface(Ci.nsIChannel).URI.spec,
+      windowId: webProgress.DOMWindowID,
+      parentWindowId: getParentWindowId(webProgress.DOMWindow),
+      status,
+      stateFlags,
+    };
+    sendAsyncMessage("Extension:StateChange", data);
+
+    if (webProgress.DOMWindow.top != webProgress.DOMWindow) {
+      let webNav = webProgress.QueryInterface(Ci.nsIWebNavigation);
+      if (!webNav.canGoBack) {
+        // For some reason we don't fire onLocationChange for the
+        // initial navigation of a sub-frame. So we need to simulate
+        // it here.
+        let data = {
+          location: request.QueryInterface(Ci.nsIChannel).URI.spec,
+          windowId: webProgress.DOMWindowID,
+          parentWindowId: getParentWindowId(webProgress.DOMWindow),
+          flags: 0,
+        };
+        sendAsyncMessage("Extension:LocationChange", data);
+      }
+    }
+  },
+
+  onLocationChange: function onLocationChange(webProgress, request, locationURI, flags) {
+    let data = {
+      location: locationURI ? locationURI.spec : "",
+      windowId: webProgress.DOMWindowID,
+      parentWindowId: getParentWindowId(webProgress.DOMWindow),
+      flags,
+    };
+    sendAsyncMessage("Extension:LocationChange", data);
+  },
+
+  QueryInterface: function QueryInterface(aIID) {
+    if (aIID.equals(Ci.nsIWebProgressListener) ||
+        aIID.equals(Ci.nsISupportsWeakReference) ||
+        aIID.equals(Ci.nsISupports)) {
+        return this;
+    }
+
+    throw Components.results.NS_ERROR_NO_INTERFACE;
+  }
+};
+
+let disabled = false;
+WebProgressListener.init();
+addEventListener("unload", () => {
+  if (!disabled) {
+    WebProgressListener.uninit();
+  }
+});
+addMessageListener("Extension:DisableWebNavigation", () => {
+  disabled = true;
+  WebProgressListener.uninit();
+});
--- a/toolkit/modules/moz.build
+++ b/toolkit/modules/moz.build
@@ -8,16 +8,18 @@ XPCSHELL_TESTS_MANIFESTS += ['tests/xpcs
 BROWSER_CHROME_MANIFESTS += ['tests/browser/browser.ini']
 MOCHITEST_MANIFESTS += ['tests/mochitest/mochitest.ini']
 MOCHITEST_CHROME_MANIFESTS += ['tests/chrome/chrome.ini']
 
 SPHINX_TREES['toolkit_modules'] = 'docs'
 
 EXTRA_JS_MODULES += [
     'addons/MatchPattern.jsm',
+    'addons/WebNavigation.jsm',
+    'addons/WebNavigationContent.js',
     'addons/WebRequest.jsm',
     'addons/WebRequestCommon.jsm',
     'addons/WebRequestContent.js',
     'Battery.jsm',
     'BinarySearch.jsm',
     'BrowserUtils.jsm',
     'CertUtils.jsm',
     'CharsetMenu.jsm',
--- a/toolkit/modules/tests/browser/browser.ini
+++ b/toolkit/modules/tests/browser/browser.ini
@@ -1,13 +1,16 @@
 [DEFAULT]
 support-files =
   dummy_page.html
   metadata_*.html
   testremotepagemanager.html
+  file_WebNavigation_page1.html
+  file_WebNavigation_page2.html
+  file_WebNavigation_page3.html
   file_WebRequest_page1.html
   file_WebRequest_page2.html
   file_image_good.png
   file_image_bad.png
   file_image_redirect.png
   file_style_good.css
   file_style_bad.css
   file_style_redirect.css
@@ -18,15 +21,16 @@ support-files =
   WebRequest_dynamic.sjs
 
 [browser_Battery.js]
 [browser_Deprecated.js]
 [browser_Finder.js]
 skip-if = e10s # Bug ?????? - test already uses content scripts, but still fails only under e10s.
 [browser_Geometry.js]
 [browser_InlineSpellChecker.js]
+[browser_WebNavigation.js]
 [browser_WebRequest.js]
 [browser_WebRequest_cookies.js]
 [browser_WebRequest_filtering.js]
 [browser_PageMetadata.js]
 [browser_RemotePageManager.js]
 [browser_RemoteWebNavigation.js]
 [browser_Troubleshoot.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/browser/browser_WebNavigation.js
@@ -0,0 +1,140 @@
+"use strict";
+
+const { interfaces: Ci, classes: Cc, utils: Cu, results: Cr } = Components;
+
+let {WebNavigation} = Cu.import("resource://gre/modules/WebNavigation.jsm", {});
+
+const BASE = "http://example.com/browser/toolkit/modules/tests/browser";
+const URL = BASE + "/file_WebNavigation_page1.html";
+const FRAME = BASE + "/file_WebNavigation_page2.html";
+const FRAME2 = BASE + "/file_WebNavigation_page3.html";
+
+const EVENTS = [
+  "onBeforeNavigate",
+  "onCommitted",
+  "onDOMContentLoaded",
+  "onCompleted",
+  "onErrorOccurred",
+  "onReferenceFragmentUpdated",
+];
+
+const REQUIRED = [
+  "onBeforeNavigate",
+  "onCommitted",
+  "onDOMContentLoaded",
+  "onCompleted",
+];
+
+let expectedBrowser;
+let received = [];
+let completedResolve;
+let waitingURL, waitingEvent;
+let rootWindowID;
+
+function gotEvent(event, details)
+{
+  if (!details.url.startsWith(BASE)) {
+    return;
+  }
+  info(`Got ${event} ${details.url} ${details.windowId} ${details.parentWindowId}`);
+
+  is(details.browser, expectedBrowser, "correct <browser> element");
+
+  received.push({url: details.url, event});
+
+  if (typeof(rootWindowID) == "undefined") {
+    rootWindowID = details.windowId;
+  }
+
+  if (details.url == URL) {
+    is(details.windowId, rootWindowID, "root window ID correct");
+  } else {
+    is(details.parentWindowId, rootWindowID, "parent window ID correct");
+    isnot(details.windowId, rootWindowID, "window ID probably okay");
+  }
+
+  isnot(details.windowId, undefined);
+  isnot(details.parentWindowId, undefined);
+
+  if (details.url == waitingURL && event == waitingEvent) {
+    completedResolve();
+  }
+}
+
+function loadViaFrameScript(url, event, script)
+{
+  // Loading via a frame script ensures that the chrome process never
+  // "gets ahead" of frame scripts in non-e10s mode.
+  received = [];
+  waitingURL = url;
+  waitingEvent = event;
+  expectedBrowser.messageManager.loadFrameScript("data:," + script, false);
+  return new Promise(resolve => { completedResolve = resolve; });
+}
+
+add_task(function* webnav_ordering() {
+  let listeners = {};
+  for (let event of EVENTS) {
+    listeners[event] = gotEvent.bind(null, event);
+    WebNavigation[event].addListener(listeners[event]);
+  }
+
+  gBrowser.selectedTab = gBrowser.addTab();
+  let browser = gBrowser.selectedBrowser;
+  expectedBrowser = browser;
+
+  yield BrowserTestUtils.browserLoaded(browser);
+
+  yield loadViaFrameScript(URL, "onCompleted", `content.location = "${URL}";`);
+
+  function checkRequired(url) {
+    for (let event of REQUIRED) {
+      let found = false;
+      for (let r of received) {
+        if (r.url == url && r.event == event) {
+          found = true;
+        }
+      }
+      ok(found, `Received event ${event} from ${url}`);
+    }
+  }
+
+  checkRequired(URL);
+  checkRequired(FRAME);
+
+  function checkBefore(action1, action2) {
+    function find(action) {
+      for (let i = 0; i < received.length; i++) {
+        if (received[i].url == action.url && received[i].event == action.event) {
+          return i;
+        }
+      }
+      return -1;
+    }
+
+    let index1 = find(action1);
+    let index2 = find(action2);
+    ok(index1 != -1, `Action ${JSON.stringify(action1)} happened`);
+    ok(index2 != -1, `Action ${JSON.stringify(action2)} happened`);
+    ok(index1 < index2, `Action ${JSON.stringify(action1)} happened before ${JSON.stringify(action2)}`);
+  }
+
+  checkBefore({url: URL, event: "onCommitted"}, {url: FRAME, event: "onBeforeNavigate"});
+  checkBefore({url: FRAME, event: "onCompleted"}, {url: URL, event: "onCompleted"});
+
+  yield loadViaFrameScript(FRAME2, "onCompleted", `content.frames[0].location = "${FRAME2}";`);
+
+  checkRequired(FRAME2);
+
+  yield loadViaFrameScript(FRAME2 + "#ref", "onReferenceFragmentUpdated",
+                           "content.frames[0].document.getElementById('elt').click();");
+
+  info("Received onReferenceFragmentUpdated from FRAME2");
+
+  gBrowser.removeCurrentTab();
+
+  for (let event of EVENTS) {
+    WebNavigation[event].removeListener(listeners[event]);
+  }
+});
+
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/browser/file_WebNavigation_page1.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+<iframe src="file_WebNavigation_page2.html" width="200" height="200"></iframe>
+
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/browser/file_WebNavigation_page2.html
@@ -0,0 +1,7 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/browser/file_WebNavigation_page3.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+<a id="elt" href="file_WebNavigation_page3.html#ref">click me</a>
+
+</body>
+</html>