Bug 1263723 - [webext] Track awesomebar user interaction for webNavigation transition types and qualifiers. r=krizsa draft
authorLuca Greco <lgreco@mozilla.com>
Mon, 09 May 2016 18:24:53 +0200
changeset 366293 62b809718adcff37197f7f8038e719d487a8343a
parent 365730 3461f3cae78495f100a0f7d3d2e0b89292d3ec02
child 520747 b969a7aff1d5073cf012254db9d0e0b5509d3f92
push id17953
push userluca.greco@alcacoop.it
push dateThu, 12 May 2016 12:39:26 +0000
reviewerskrizsa
bugs1263723
milestone49.0a1
Bug 1263723 - [webext] Track awesomebar user interaction for webNavigation transition types and qualifiers. r=krizsa - introducing tabTransitionData in the webNavigation internals - listen for the "autocomplete-did-enter-text" topic notified on the observer service - add support to from_address_bar transition qualifier and auto_bookmark/keyword/generated transition types MozReview-Commit-ID: 7krQiJlnc1d
browser/components/extensions/test/browser/browser.ini
browser/components/extensions/test/browser/browser_ext_webNavigation_urlbar_transitions.js
browser/components/extensions/test/browser/searchSuggestionEngine.sjs
browser/components/extensions/test/browser/searchSuggestionEngine.xml
toolkit/components/extensions/ext-webNavigation.js
toolkit/modules/addons/WebNavigation.jsm
--- a/browser/components/extensions/test/browser/browser.ini
+++ b/browser/components/extensions/test/browser/browser.ini
@@ -8,16 +8,19 @@ support-files =
   file_popup_api_injection_a.html
   file_popup_api_injection_b.html
   file_iframe_document.html
   file_iframe_document.sjs
   file_bypass_cache.sjs
   file_language_fr_en.html
   file_language_ja.html
   file_dummy.html
+  searchSuggestionEngine.xml
+  searchSuggestionEngine.sjs
+
 
 [browser_ext_browserAction_context.js]
 [browser_ext_browserAction_disabled.js]
 [browser_ext_browserAction_pageAction_icon.js]
 [browser_ext_browserAction_popup.js]
 [browser_ext_browserAction_simple.js]
 [browser_ext_commands_execute_page_action.js]
 [browser_ext_commands_getAll.js]
@@ -60,16 +63,17 @@ support-files =
 [browser_ext_tabs_reload.js]
 [browser_ext_tabs_reload_bypass_cache.js]
 [browser_ext_tabs_sendMessage.js]
 [browser_ext_tabs_update.js]
 [browser_ext_tabs_zoom.js]
 [browser_ext_tabs_update_url.js]
 [browser_ext_topwindowid.js]
 [browser_ext_webNavigation_getFrames.js]
+[browser_ext_webNavigation_urlbar_transitions.js]
 [browser_ext_windows.js]
 [browser_ext_windows_create.js]
 tags = fullscreen
 [browser_ext_windows_create_tabId.js]
 [browser_ext_windows_events.js]
 [browser_ext_windows_size.js]
 skip-if = os == 'mac' # Fails when windows are randomly opened in fullscreen mode
 [browser_ext_windows_update.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_urlbar_transitions.js
@@ -0,0 +1,261 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+                                  "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesTestUtils",
+                                  "resource://testing-common/PlacesTestUtils.jsm");
+
+const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches";
+const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml";
+
+function* addBookmark(bookmark) {
+  if (bookmark.keyword) {
+    yield PlacesUtils.keywords.insert({
+      keyword: bookmark.keyword,
+      url: bookmark.url,
+    });
+  }
+
+  yield PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+    url: bookmark.url,
+    title: bookmark.title,
+  });
+
+  registerCleanupFunction(function* () {
+    yield PlacesUtils.bookmarks.eraseEverything();
+  });
+}
+
+function addSearchEngine(basename) {
+  return new Promise((resolve, reject) => {
+    info("Waiting for engine to be added: " + basename);
+    let url = getRootDirectory(gTestPath) + basename;
+    Services.search.addEngine(url, null, "", false, {
+      onSuccess: (engine) => {
+        info(`Search engine added: ${basename}`);
+        registerCleanupFunction(() => Services.search.removeEngine(engine));
+        resolve(engine);
+      },
+      onError: (errCode) => {
+        ok(false, `addEngine failed with error code ${errCode}`);
+        reject();
+      },
+    });
+  });
+}
+
+function* prepareSearchEngine() {
+  let oldCurrentEngine = Services.search.currentEngine;
+  Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, true);
+  let engine = yield addSearchEngine(TEST_ENGINE_BASENAME);
+  Services.search.currentEngine = engine;
+
+  registerCleanupFunction(function* () {
+    Services.prefs.clearUserPref(SUGGEST_URLBAR_PREF);
+    Services.search.currentEngine = oldCurrentEngine;
+
+    // Make sure the popup is closed for the next test.
+    gURLBar.blur();
+    gURLBar.popup.selectedIndex = -1;
+    gURLBar.popup.hidePopup();
+    ok(!gURLBar.popup.popupOpen, "popup should be closed");
+
+    // Clicking suggestions causes visits to search results pages, so clear that
+    // history now.
+    yield PlacesTestUtils.clearHistory();
+  });
+}
+
+add_task(function* test_webnavigation_urlbar_typed_transitions() {
+  function backgroundScript() {
+    browser.webNavigation.onCommitted.addListener((msg) => {
+      browser.test.assertEq("http://example.com/?q=typed", msg.url,
+                            "Got the expected url");
+      // assert from_address_bar transition qualifier
+      browser.test.assertTrue(msg.transitionQualifiers &&
+                          msg.transitionQualifiers.includes("from_address_bar"),
+                              "Got the expected from_address_bar transitionQualifier");
+      browser.test.assertEq("typed", msg.transitionType,
+                            "Got the expected transitionType");
+      browser.test.notifyPass("webNavigation.from_address_bar.typed");
+    });
+
+    browser.test.sendMessage("ready");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: backgroundScript,
+    manifest: {
+      permissions: ["webNavigation"],
+    },
+  });
+
+  yield extension.startup();
+
+  yield extension.awaitMessage("ready");
+
+  gURLBar.focus();
+  gURLBar.textValue = "http://example.com/?q=typed";
+
+  EventUtils.synthesizeKey("VK_RETURN", {altKey: true});
+
+  yield extension.awaitFinish("webNavigation.from_address_bar.typed");
+
+  yield extension.unload();
+  info("extension unloaded");
+});
+
+add_task(function* test_webnavigation_urlbar_bookmark_transitions() {
+  function backgroundScript() {
+    browser.webNavigation.onCommitted.addListener((msg) => {
+      browser.test.assertEq("http://example.com/?q=bookmark", msg.url,
+                            "Got the expected url");
+
+      // assert from_address_bar transition qualifier
+      browser.test.assertTrue(msg.transitionQualifiers &&
+                          msg.transitionQualifiers.includes("from_address_bar"),
+                              "Got the expected from_address_bar transitionQualifier");
+      browser.test.assertEq("auto_bookmark", msg.transitionType,
+                            "Got the expected transitionType");
+      browser.test.notifyPass("webNavigation.from_address_bar.auto_bookmark");
+    });
+
+    browser.test.sendMessage("ready");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: backgroundScript,
+    manifest: {
+      permissions: ["webNavigation"],
+    },
+  });
+
+  yield addBookmark({
+    title: "Bookmark To Click",
+    url: "http://example.com/?q=bookmark",
+  });
+
+  yield extension.startup();
+
+  yield extension.awaitMessage("ready");
+
+  gURLBar.focus();
+  gURLBar.value = "Bookmark To Click";
+  gURLBar.controller.startSearch("Bookmark To Click");
+
+  let item;
+
+  yield BrowserTestUtils.waitForCondition(() => {
+    item = gURLBar.popup.richlistbox.getItemAtIndex(1);
+    return item;
+  });
+
+  item.click();
+  yield extension.awaitFinish("webNavigation.from_address_bar.auto_bookmark");
+
+  yield extension.unload();
+  info("extension unloaded");
+});
+
+add_task(function* test_webnavigation_urlbar_keyword_transition() {
+  function backgroundScript() {
+    browser.webNavigation.onCommitted.addListener((msg) => {
+      browser.test.assertEq(`http://example.com/?q=search`, msg.url,
+                            "Got the expected url");
+
+      // assert from_address_bar transition qualifier
+      browser.test.assertTrue(msg.transitionQualifiers &&
+                          msg.transitionQualifiers.includes("from_address_bar"),
+                              "Got the expected from_address_bar transitionQualifier");
+      browser.test.assertEq("keyword", msg.transitionType,
+                            "Got the expected transitionType");
+      browser.test.notifyPass("webNavigation.from_address_bar.keyword");
+    });
+
+    browser.test.sendMessage("ready");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: backgroundScript,
+    manifest: {
+      permissions: ["webNavigation"],
+    },
+  });
+
+  yield addBookmark({
+    title: "Test Keyword",
+    url: "http://example.com/?q=%s",
+    keyword: "testkw",
+  });
+
+  yield extension.startup();
+
+  yield extension.awaitMessage("ready");
+
+  gURLBar.focus();
+  gURLBar.value = "testkw search";
+  gURLBar.controller.startSearch("testkw search");
+
+  yield BrowserTestUtils.waitForCondition(() => {
+    return gURLBar.popup.input.controller.matchCount;
+  });
+
+  let item = gURLBar.popup.richlistbox.getItemAtIndex(0);
+  item.click();
+
+  yield extension.awaitFinish("webNavigation.from_address_bar.keyword");
+
+  yield extension.unload();
+  info("extension unloaded");
+});
+
+add_task(function* test_webnavigation_urlbar_search_transitions() {
+  function backgroundScript() {
+    browser.webNavigation.onCommitted.addListener((msg) => {
+      browser.test.assertEq("http://mochi.test:8888/", msg.url,
+                            "Got the expected url");
+
+      // assert from_address_bar transition qualifier
+      browser.test.assertTrue(msg.transitionQualifiers &&
+                          msg.transitionQualifiers.includes("from_address_bar"),
+                              "Got the expected from_address_bar transitionQualifier");
+      browser.test.assertEq("generated", msg.transitionType,
+                            "Got the expected 'generated' transitionType");
+      browser.test.notifyPass("webNavigation.from_address_bar.generated");
+    });
+
+    browser.test.sendMessage("ready");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: backgroundScript,
+    manifest: {
+      permissions: ["webNavigation"],
+    },
+  });
+
+  yield extension.startup();
+
+  yield extension.awaitMessage("ready");
+
+  yield prepareSearchEngine();
+
+  gURLBar.focus();
+  gURLBar.value = "foo";
+  gURLBar.controller.startSearch("foo");
+
+  yield BrowserTestUtils.waitForCondition(() => {
+    return gURLBar.popup.input.controller.matchCount;
+  });
+
+  let item = gURLBar.popup.richlistbox.getItemAtIndex(0);
+  item.click();
+
+  yield extension.awaitFinish("webNavigation.from_address_bar.generated");
+
+  yield extension.unload();
+  info("extension unloaded");
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/searchSuggestionEngine.sjs
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function handleRequest(req, resp) {
+  let suffixes = ["foo", "bar"];
+  let data = [req.queryString, suffixes.map(s => req.queryString + s)];
+  resp.setHeader("Content-Type", "application/json", false);
+  resp.write(JSON.stringify(data));
+}
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/searchSuggestionEngine.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Any copyright is dedicated to the Public Domain.
+   - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>browser_searchSuggestionEngine searchSuggestionEngine.xml</ShortName>
+<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/extensions/test/browser/searchSuggestionEngine.sjs?{searchTerms}"/>
+<Url type="text/html" method="GET" template="http://mochi.test:8888/" rel="searchform"/>
+</SearchPlugin>
--- a/toolkit/components/extensions/ext-webNavigation.js
+++ b/toolkit/components/extensions/ext-webNavigation.js
@@ -27,23 +27,34 @@ const frameTransitions = {
   anyFrame: {
     qualifiers: ["server_redirect", "client_redirect", "forward_back"],
   },
   topFrame: {
     types: ["reload", "form_submit"],
   },
 };
 
+const tabTransitions = {
+  topFrame: {
+    qualifiers: ["from_address_bar"],
+    types: ["auto_bookmark", "typed", "keyword", "generated", "link"],
+  },
+  subFrame: {
+    types: ["manual_subframe"],
+  },
+};
+
 function isTopLevelFrame({frameId, parentFrameId}) {
   return frameId == 0 && parentFrameId == -1;
 }
 
 function fillTransitionProperties(eventName, src, dst) {
   if (eventName == "onCommitted" || eventName == "onHistoryStateUpdated") {
     let frameTransitionData = src.frameTransitionData || {};
+    let tabTransitionData = src.tabTransitionData || {};
 
     let transitionType, transitionQualifiers = [];
 
     // Fill transition properties for any frame.
     for (let qualifier of frameTransitions.anyFrame.qualifiers) {
       if (frameTransitionData[qualifier]) {
         transitionQualifiers.push(qualifier);
       }
@@ -51,24 +62,37 @@ function fillTransitionProperties(eventN
 
     if (isTopLevelFrame(dst)) {
       for (let type of frameTransitions.topFrame.types) {
         if (frameTransitionData[type]) {
           transitionType = type;
         }
       }
 
+      for (let qualifier of tabTransitions.topFrame.qualifiers) {
+        if (tabTransitionData[qualifier]) {
+          transitionQualifiers.push(qualifier);
+        }
+      }
+
+      for (let type of tabTransitions.topFrame.types) {
+        if (tabTransitionData[type]) {
+          transitionType = type;
+        }
+      }
+
       // If transitionType is not defined, defaults it to "link".
       if (!transitionType) {
         transitionType = defaultTransitionTypes.topFrame;
       }
     } else {
       // If it is sub-frame, transitionType defaults it to "auto_subframe",
       // "manual_subframe" is set only in case of a recent user interaction.
-      transitionType = defaultTransitionTypes.subFrame;
+      transitionType = tabTransitionData.link ?
+        "manual_subframe" : defaultTransitionTypes.subFrame;
     }
 
     // Fill the transition properties in the webNavigation event object.
     dst.transitionType = transitionType;
     dst.transitionQualifiers = transitionQualifiers;
   }
 }
 
--- a/toolkit/modules/addons/WebNavigation.jsm
+++ b/toolkit/modules/addons/WebNavigation.jsm
@@ -8,31 +8,48 @@ 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");
 
+XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow",
+                                  "resource:///modules/RecentWindow.jsm");
+
+// Maximum amount of time that can be passed and still consider
+// the data recent (similar to how is done in nsNavHistory,
+// e.g. nsNavHistory::CheckIsRecentEvent, but with a lower threshold value).
+const RECENT_DATA_THRESHOLD = 5 * 1000000;
+
 // TODO:
 // onCreatedNavigationTarget
 
 var Manager = {
   listeners: new Map(),
 
   init() {
+    // Collect recent tab transition data in a WeakMap:
+    //   browser -> tabTransitionData
+    this.recentTabTransitionData = new WeakMap();
+    Services.obs.addObserver(this, "autocomplete-did-enter-text", true);
+
     Services.mm.addMessageListener("Extension:DOMContentLoaded", this);
     Services.mm.addMessageListener("Extension:StateChange", this);
     Services.mm.addMessageListener("Extension:DocumentChange", this);
     Services.mm.addMessageListener("Extension:HistoryChange", this);
     Services.mm.loadFrameScript("resource://gre/modules/WebNavigationContent.js", true);
   },
 
   uninit() {
+    // Stop collecting recent tab transition data and reset the WeakMap.
+    Services.obs.removeObserver(this, "autocomplete-did-enter-text", true);
+    this.recentTabTransitionData = new WeakMap();
+
     Services.mm.removeMessageListener("Extension:StateChange", this);
     Services.mm.removeMessageListener("Extension:DocumentChange", this);
     Services.mm.removeMessageListener("Extension:HistoryChange", this);
     Services.mm.removeMessageListener("Extension:DOMContentLoaded", this);
     Services.mm.removeDelayedFrameScript("resource://gre/modules/WebNavigationContent.js");
     Services.mm.broadcastAsyncMessage("Extension:DisableWebNavigation");
   },
 
@@ -58,16 +75,153 @@ var Manager = {
       this.listeners.delete(type);
     }
 
     if (this.listeners.size == 0) {
       this.uninit();
     }
   },
 
+  /**
+   *  Support nsIObserver interface to observe the urlbar autocomplete events used
+   *  to keep track of the urlbar user interaction.
+   */
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]),
+
+  /**
+   *  Observe autocomplete-did-enter-text topic to track the user interaction with
+   *  the awesome bar.
+   */
+  observe: function(subject, topic, data) {
+    if (topic == "autocomplete-did-enter-text") {
+      this.onURLBarAutoCompletion(subject, topic, data);
+    }
+  },
+
+  /**
+   *  Recognize the type of urlbar user interaction (e.g. typing a new url,
+   *  clicking on an url generated from a searchengine or a keyword, or a
+   *  bookmark found by the urlbar autocompletion).
+   */
+  onURLBarAutoCompletion(subject, topic, data) {
+    if (subject && subject instanceof Ci.nsIAutoCompleteInput) {
+      // We are only interested in urlbar autocompletion events
+      if (subject.id !== "urlbar") {
+        return;
+      }
+
+      let controller = subject.popup.view.QueryInterface(Ci.nsIAutoCompleteController);
+      let idx = subject.popup.selectedIndex;
+
+      let tabTransistionData = {
+        from_address_bar: true,
+      };
+
+      if (idx < 0 || idx >= controller.matchCount) {
+        // Recognize when no valid autocomplete results has been selected.
+        tabTransistionData.typed = true;
+      } else {
+        let value = controller.getValueAt(idx);
+        let action = subject._parseActionUrl(value);
+
+        if (action) {
+          // Detect keywork and generated and more typed scenarios.
+          switch (action.type) {
+            case "keyword":
+              tabTransistionData.keyword = true;
+              break;
+            case "searchengine":
+            case "searchsuggestion":
+              tabTransistionData.generated = true;
+              break;
+            case "visiturl":
+              // Visiturl are autocompletion results related to
+              // history suggestions.
+              tabTransistionData.typed = true;
+              break;
+            case "remotetab":
+              // Remote tab are autocomplete results related to
+              // tab urls from a remote synchronized Firefox.
+              tabTransistionData.typed = true;
+              break;
+            case "switchtab":
+              // This "switchtab" autocompletion should be ignored, because
+              // it is not related to a navigation.
+              return;
+            default:
+              // Fallback on "typed" if unable to detect a known moz-action type.
+              tabTransistionData.typed = true;
+          }
+        } else {
+          // Special handling for bookmark urlbar autocompletion
+          // (which happens when we got a null action and a valid selectedIndex)
+          let styles = new Set(controller.getStyleAt(idx).split(/\s+/));
+
+          if (styles.has("bookmark")) {
+            tabTransistionData.auto_bookmark = true;
+          } else {
+            // Fallback on "typed" if unable to detect a specific actionType
+            // (and when in the styles there are "autofill" or "history").
+            tabTransistionData.typed = true;
+          }
+        }
+      }
+
+      this.setRecentTabTransitionData(tabTransistionData);
+    }
+  },
+
+  /**
+   *  Keep track of a recent user interaction and cache it in a
+   *  map associated to the current selected tab.
+   */
+  setRecentTabTransitionData(tabTransitionData) {
+    let window = RecentWindow.getMostRecentBrowserWindow();
+    if (window && window.gBrowser && window.gBrowser.selectedTab &&
+        window.gBrowser.selectedTab.linkedBrowser) {
+      let browser = window.gBrowser.selectedTab.linkedBrowser;
+
+      // Get recent tab transition data to update if any.
+      let prevData = this.getAndForgetRecentTabTransitionData(browser);
+
+      let newData = Object.assign(
+        {time: Date.now()},
+        prevData,
+        tabTransitionData
+      );
+      this.recentTabTransitionData.set(browser, newData);
+    }
+  },
+
+  /**
+   *  Retrieve recent data related to a recent user interaction give a
+   *  given tab's linkedBrowser (only if is is more recent than the
+   *  `RECENT_DATA_THRESHOLD`).
+   *
+   *  NOTE: this method is used to retrieve the tab transition data
+   *  collected when one of the `onCommitted`, `onHistoryStateUpdated`
+   *  or `onReferenceFragmentUpdated` events has been received.
+   */
+  getAndForgetRecentTabTransitionData(browser) {
+    let data = this.recentTabTransitionData.get(browser);
+    this.recentTabTransitionData.delete(browser);
+
+    // Return an empty object if there isn't any tab transition data
+    // or if it's less recent than RECENT_DATA_THRESHOLD.
+    if (!data || (data.time - Date.now()) > RECENT_DATA_THRESHOLD) {
+      return {};
+    }
+
+    return data;
+  },
+
+  /**
+   *  Receive messages from the WebNavigationContent.js framescript
+   *  over message manager events.
+   */
   receiveMessage({name, data, target}) {
     switch (name) {
       case "Extension:StateChange":
         this.onStateChange(target, data);
         break;
 
       case "Extension:DocumentChange":
         this.onDocumentChange(target, data);
@@ -100,26 +254,28 @@ var Manager = {
     }
   },
 
   onDocumentChange(browser, data) {
     let extra = {
       url: data.location,
       // Transition data which is coming from the content process.
       frameTransitionData: data.frameTransitionData,
+      tabTransitionData: this.getAndForgetRecentTabTransitionData(browser),
     };
 
     this.fire("onCommitted", browser, data, extra);
   },
 
   onHistoryChange(browser, data) {
     let extra = {
       url: data.location,
       // Transition data which is coming from the content process.
       frameTransitionData: data.frameTransitionData,
+      tabTransitionData: this.getAndForgetRecentTabTransitionData(browser),
     };
 
     if (data.isReferenceFragmentUpdated) {
       this.fire("onReferenceFragmentUpdated", browser, data, extra);
     } else if (data.isHistoryStateUpdated) {
       this.fire("onHistoryStateUpdated", browser, data, extra);
     }
   },