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
--- 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);
}
},