Bug 1210410 Implement search messages for the Remote New Tab draft
authorUrsula Sarracini
Tue, 10 May 2016 10:32:31 -0400 (2016-05-10)
changeset 365320 8640fdb601118e000e8f5edaa54a468ddb118453
parent 365273 1579b9e2e50f3a27ad02d58cc9170c91e0973fec
child 520521 42ee15b7ba577c7685da14cd07e5dc8d60b060f9
push id17705
push userusarracini@mozilla.com
push dateTue, 10 May 2016 14:32:51 +0000 (2016-05-10)
bugs1210410
milestone49.0a1
Bug 1210410 Implement search messages for the Remote New Tab MozReview-Commit-ID: 8trDqRegP3G
browser/components/newtab/NewTabMessages.jsm
browser/components/newtab/NewTabPrefsProvider.jsm
browser/components/newtab/NewTabSearchProvider.jsm
browser/components/newtab/moz.build
browser/components/newtab/tests/browser/browser.ini
browser/components/newtab/tests/browser/browser_newtabmessages.js
browser/components/newtab/tests/browser/newtabmessages_search.html
browser/components/newtab/tests/xpcshell/test_NewTabSearchProvider.js
browser/components/newtab/tests/xpcshell/xpcshell.ini
browser/modules/ContentSearch.jsm
--- a/browser/components/newtab/NewTabMessages.jsm
+++ b/browser/components/newtab/NewTabMessages.jsm
@@ -1,58 +1,89 @@
 /*global
   NewTabWebChannel,
   NewTabPrefsProvider,
   PlacesProvider,
+  PreviewProvider,
+  NewTabSearchProvider,
   Preferences,
-  XPCOMUtils
+  XPCOMUtils,
+  Task
 */
 
 /* exported NewTabMessages */
 
 "use strict";
 
 const {utils: Cu} = Components;
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesProvider",
                                   "resource:///modules/PlacesProvider.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PreviewProvider",
                                   "resource:///modules/PreviewProvider.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NewTabPrefsProvider",
                                   "resource:///modules/NewTabPrefsProvider.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NewTabSearchProvider",
+                                  "resource:///modules/NewTabSearchProvider.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NewTabWebChannel",
                                   "resource:///modules/NewTabWebChannel.jsm");
 
 this.EXPORTED_SYMBOLS = ["NewTabMessages"];
 
 const PREF_ENABLED = "browser.newtabpage.remote";
+const CURRENT_ENGINE = "browser-search-engine-modified";
 
 // Action names are from the content's perspective. in from chrome == out from content
 // Maybe replace the ACTION objects by a bi-directional Map a bit later?
 const ACTIONS = {
   inboundActions: [
     "REQUEST_PREFS",
     "REQUEST_THUMB",
     "REQUEST_FRECENT",
+    "REQUEST_UISTRINGS",
+    "REQUEST_SEARCH_SUGGESTIONS",
+    "REQUEST_MANAGE_ENGINES",
+    "REQUEST_SEARCH_STATE",
+    "REQUEST_REMOVE_FORM_HISTORY",
+    "REQUEST_PERFORM_SEARCH",
+    "REQUEST_CYCLE_ENGINE",
   ],
   prefs: {
     inPrefs: "REQUEST_PREFS",
     outPrefs: "RECEIVE_PREFS",
   },
   preview: {
     inThumb: "REQUEST_THUMB",
     outThumb: "RECEIVE_THUMB",
   },
   links: {
     inFrecent: "REQUEST_FRECENT",
     outFrecent: "RECEIVE_FRECENT",
     outPlacesChange: "RECEIVE_PLACES_CHANGE",
   },
+  search: {
+    inSearch: {
+        UIStrings: "REQUEST_UISTRINGS",
+        suggestions: "REQUEST_SEARCH_SUGGESTIONS",
+        manageEngines: "REQUEST_MANAGE_ENGINES",
+        state: "REQUEST_SEARCH_STATE",
+        removeFormHistory: "REQUEST_REMOVE_FORM_HISTORY",
+        performSearch: "REQUEST_PERFORM_SEARCH",
+        cycleEngine: "REQUEST_CYCLE_ENGINE"
+    },
+    outSearch: {
+      UIStrings: "RECEIVE_UISTRINGS",
+      suggestions: "RECEIVE_SEARCH_SUGGESTIONS",
+      state: "RECEIVE_SEARCH_STATE",
+      currentEngine: "RECEIVE_CURRENT_ENGINE"
+    },
+  }
 };
 
 let NewTabMessages = {
 
   _prefs: {},
 
   /** NEWTAB EVENT HANDLERS **/
 
@@ -70,27 +101,80 @@ let NewTabMessages = {
         });
         break;
       case ACTIONS.links.inFrecent:
         // Return to the originator the top frecent links
         PlacesProvider.links.getLinks().then(links => {
           NewTabWebChannel.send(ACTIONS.links.outFrecent, links, target);
         });
         break;
+      case ACTIONS.search.inSearch.UIStrings:
+        // Return to the originator all search strings to display
+        let strings = NewTabSearchProvider.search.searchSuggestionUIStrings;
+        NewTabWebChannel.send(ACTIONS.search.outSearch.UIStrings, strings, target);
+        break;
+      case ACTIONS.search.inSearch.suggestions:
+        // Return to the originator all search suggestions
+        Task.spawn(function*() {
+          try {
+            let {engineName, searchString} = data;
+            let suggestions = yield NewTabSearchProvider.search.asyncGetSuggestions(engineName, searchString, target);
+            NewTabWebChannel.send(ACTIONS.search.outSearch.suggestions, suggestions, target);
+          } catch (e) {
+            Cu.reportError(e);
+          }
+        });
+        break;
+      case ACTIONS.search.inSearch.manageEngines:
+        // Open about:preferences to manage search state
+        NewTabSearchProvider.search.manageEngines(target.browser);
+        break;
+      case ACTIONS.search.inSearch.state:
+        // Return the state of the search component (i.e current engine and visible engine details)
+        Task.spawn(function*() {
+          try {
+            let state = yield NewTabSearchProvider.search.asyncGetState();
+            NewTabWebChannel.broadcast(ACTIONS.search.outSearch.state, state);
+          } catch (e) {
+            Cu.reportError(e);
+          }
+        });
+        break;
+      case ACTIONS.search.inSearch.removeFormHistory:
+        // Remove a form history entry from the search component
+        let suggestion = data;
+        NewTabSearchProvider.search.removeFormHistory(target, suggestion);
+        break;
+      case ACTIONS.search.inSearch.performSearch:
+        // Perform a search
+        NewTabSearchProvider.search.asyncPerformSearch(target, data).catch(Cu.reportError);
+        break;
+      case ACTIONS.search.inSearch.cycleEngine:
+        // Set the new current engine
+        NewTabSearchProvider.search.asyncCycleEngine(data).catch(Cu.reportError);
+        break;
     }
   },
 
   /*
    * Broadcast places change to all open newtab pages
    */
   handlePlacesChange(type, data) {
     NewTabWebChannel.broadcast(ACTIONS.links.outPlacesChange, {type, data});
   },
 
   /*
+   * Broadcast current engine has changed to all open newtab pages
+   */
+  _handleCurrentEngineChange(name, value) { //jshint unused: false
+    let engine = value;
+    NewTabWebChannel.broadcast(ACTIONS.search.outSearch.currentEngine, engine);
+  },
+
+  /*
    * Broadcast preference changes to all open newtab pages
    */
   handlePrefChange(actionName, value) {
     let prefChange = {};
     prefChange[actionName] = value;
     NewTabWebChannel.broadcast(ACTIONS.prefs.outPrefs, prefChange);
   },
 
@@ -102,28 +186,32 @@ let NewTabMessages = {
         this.init();
       }
     }
   },
 
   init() {
     this.handleContentRequest = this.handleContentRequest.bind(this);
     this._handleEnabledChange = this._handleEnabledChange.bind(this);
+    this._handleCurrentEngineChange = this._handleCurrentEngineChange.bind(this);
 
     PlacesProvider.links.init();
     NewTabPrefsProvider.prefs.init();
+    NewTabSearchProvider.search.init();
     NewTabWebChannel.init();
 
     this._prefs.enabled = Preferences.get(PREF_ENABLED, false);
 
     if (this._prefs.enabled) {
       for (let action of ACTIONS.inboundActions) {
         NewTabWebChannel.on(action, this.handleContentRequest);
       }
+
       NewTabPrefsProvider.prefs.on(PREF_ENABLED, this._handleEnabledChange);
+      NewTabSearchProvider.search.on(CURRENT_ENGINE, this._handleCurrentEngineChange);
 
       for (let pref of NewTabPrefsProvider.newtabPagePrefSet) {
         NewTabPrefsProvider.prefs.on(pref, this.handlePrefChange);
       }
 
       PlacesProvider.links.on("deleteURI", this.handlePlacesChange);
       PlacesProvider.links.on("clearHistory", this.handlePlacesChange);
       PlacesProvider.links.on("linkChanged", this.handlePlacesChange);
@@ -131,23 +219,25 @@ let NewTabMessages = {
     }
   },
 
   uninit() {
     this._prefs.enabled = Preferences.get(PREF_ENABLED, false);
 
     if (this._prefs.enabled) {
       NewTabPrefsProvider.prefs.off(PREF_ENABLED, this._handleEnabledChange);
+      NewTabSearchProvider.search.off(CURRENT_ENGINE, this._handleCurrentEngineChange);
 
       for (let action of ACTIONS.inboundActions) {
         NewTabWebChannel.off(action, this.handleContentRequest);
       }
 
       for (let pref of NewTabPrefsProvider.newtabPagePrefSet) {
         NewTabPrefsProvider.prefs.off(pref, this.handlePrefChange);
       }
     }
 
     PlacesProvider.links.uninit();
     NewTabPrefsProvider.prefs.uninit();
+    NewTabSearchProvider.search.uninit();
     NewTabWebChannel.uninit();
   }
 };
--- a/browser/components/newtab/NewTabPrefsProvider.jsm
+++ b/browser/components/newtab/NewTabPrefsProvider.jsm
@@ -23,26 +23,28 @@ const gPrefsMap = new Map([
   ["browser.newtabpage.enabled", "bool"],
   ["browser.newtabpage.enhanced", "bool"],
   ["browser.newtabpage.introShown", "bool"],
   ["browser.newtabpage.updateIntroShown", "bool"],
   ["browser.newtabpage.pinned", "str"],
   ["browser.newtabpage.blocked", "str"],
   ["intl.locale.matchOS", "bool"],
   ["general.useragent.locale", "localized"],
+  ["browser.search.hiddenOneOffs", "str"],
 ]);
 
 // prefs that are important for the newtab page
 const gNewtabPagePrefs = new Set([
   "browser.newtabpage.enabled",
   "browser.newtabpage.enhanced",
   "browser.newtabpage.pinned",
   "browser.newtabpage.blocked",
   "browser.newtabpage.introShown",
-  "browser.newtabpage.updateIntroShown"
+  "browser.newtabpage.updateIntroShown",
+  "browser.search.hiddenOneOffs",
 ]);
 
 let PrefsProvider = function PrefsProvider() {
   EventEmitter.decorate(this);
 };
 
 PrefsProvider.prototype = {
 
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/NewTabSearchProvider.jsm
@@ -0,0 +1,112 @@
+/* global XPCOMUtils, ContentSearch, Task, Services, EventEmitter */
+/* exported NewTabSearchProvider */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["NewTabSearchProvider"];
+
+const {utils: Cu, interfaces: Ci} = Components;
+const CURRENT_ENGINE = "browser-search-engine-modified";
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "ContentSearch",
+                                  "resource:///modules/ContentSearch.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "EventEmitter", function() {
+  const {EventEmitter} = Cu.import("resource://devtools/shared/event-emitter.js", {});
+  return EventEmitter;
+});
+
+function SearchProvider() {
+  EventEmitter.decorate(this);
+}
+
+SearchProvider.prototype = {
+
+  observe(subject, topic, data) { // jshint unused:false
+    switch (data) {
+      case "engine-current":
+        if (topic === CURRENT_ENGINE) {
+          Task.spawn(function* () {
+            try {
+              let state = yield ContentSearch.currentStateObj(true);
+              let engine = state.currentEngine;
+              this.emit(CURRENT_ENGINE, engine);
+            } catch (e) {
+              Cu.reportError(e);
+            }
+          }.bind(this));
+        }
+        break;
+      case "engine-default":
+        // engine-default is always sent with engine-current and isn't
+        // relevant to content searches.
+        break;
+      default:
+        Cu.reportError(new Error("NewTabSearchProvider observing unknown topic"));
+        break;
+    }
+  },
+
+  init() {
+    try {
+      Services.obs.addObserver(this, CURRENT_ENGINE, true);
+    } catch (e) {
+      Cu.reportError(e);
+    }
+  },
+
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
+    Ci.nsISupportsWeakReference
+  ]),
+
+  uninit() {
+    try {
+      Services.obs.removeObserver(this, CURRENT_ENGINE, true);
+    } catch (e) {
+      Cu.reportError(e);
+    }
+  },
+
+  get searchSuggestionUIStrings() {
+    return ContentSearch.searchSuggestionUIStrings;
+  },
+
+  removeFormHistory({browser}, suggestion) {
+    ContentSearch.removeFormHistoryEntry({target: browser}, suggestion);
+  },
+
+  manageEngines(browser) {
+    const browserWin = browser.ownerDocument.defaultView;
+    browserWin.openPreferences("paneSearch");
+  },
+
+  asyncGetState: Task.async(function*() {
+    let state = yield ContentSearch.currentStateObj(true);
+    return state;
+  }),
+
+  asyncPerformSearch: Task.async(function*({browser}, searchData) {
+    ContentSearch.performSearch({target: browser}, searchData);
+    yield ContentSearch.addFormHistoryEntry({target: browser}, searchData.searchString);
+  }),
+
+  asyncCycleEngine: Task.async(function*(engineName) {
+    Services.search.currentEngine = Services.search.getEngineByName(engineName);
+    let state = yield ContentSearch.currentStateObj(true);
+    let newEngine = state.currentEngine;
+    this.emit(CURRENT_ENGINE, newEngine);
+  }),
+
+  asyncGetSuggestions: Task.async(function*(engineName, searchString, target) {
+    let suggestions = ContentSearch.getSuggestions(engineName, searchString, target.browser);
+    return suggestions;
+  }),
+};
+
+const NewTabSearchProvider = {
+  search: new SearchProvider(),
+};
--- a/browser/components/newtab/moz.build
+++ b/browser/components/newtab/moz.build
@@ -9,16 +9,17 @@ BROWSER_CHROME_MANIFESTS += ['tests/brow
 XPCSHELL_TESTS_MANIFESTS += [
     'tests/xpcshell/xpcshell.ini',
 ]
 
 EXTRA_JS_MODULES += [
     'NewTabMessages.jsm',
     'NewTabPrefsProvider.jsm',
     'NewTabRemoteResources.jsm',
+    'NewTabSearchProvider.jsm',
     'NewTabURL.jsm',
     'NewTabWebChannel.jsm',
     'PlacesProvider.jsm',
     'PreviewProvider.jsm'
 ]
 
 XPIDL_SOURCES += [
     'nsIAboutNewTabService.idl',
--- a/browser/components/newtab/tests/browser/browser.ini
+++ b/browser/components/newtab/tests/browser/browser.ini
@@ -1,14 +1,15 @@
 [DEFAULT]
 support-files =
   blue_page.html
   dummy_page.html
   newtabwebchannel_basic.html
   newtabmessages_places.html
   newtabmessages_prefs.html
   newtabmessages_preview.html
+  newtabmessages_search.html
 
 [browser_PreviewProvider.js]
 [browser_remotenewtab_pageloads.js]
 [browser_newtab_overrides.js]
 [browser_newtabmessages.js]
 [browser_newtabwebchannel.js]
--- a/browser/components/newtab/tests/browser/browser_newtabmessages.js
+++ b/browser/components/newtab/tests/browser/browser_newtabmessages.js
@@ -1,13 +1,15 @@
-/* globals Cu, XPCOMUtils, Preferences, is, registerCleanupFunction, NewTabWebChannel, PlacesTestUtils, Task */
+/* globals Cu, XPCOMUtils, Preferences, is, registerCleanupFunction, NewTabWebChannel,
+PlacesTestUtils, NewTabMessages, ok, Services, PlacesUtils, NetUtil, Task */
 
 "use strict";
 
 Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NewTabWebChannel",
                                   "resource:///modules/NewTabWebChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NewTabMessages",
                                   "resource:///modules/NewTabMessages.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesTestUtils",
                                   "resource://testing-common/PlacesTestUtils.jsm");
 
@@ -156,8 +158,65 @@ add_task(function* placesMessages_reques
         resolve();
       });
     });
     yield PlacesTestUtils.clearHistory();
     yield placesChangeAck;
   });
   yield cleanup();
 });
+
+/*
+ * Sanity tests for search messages
+ */
+add_task(function* searchMessages_request() {
+  yield setup();
+  let testURL = "https://example.com/browser/browser/components/newtab/tests/browser/newtabmessages_search.html";
+
+  // create dummy test engines
+  Services.search.addEngineWithDetails("Engine1", "", "", "", "GET",
+    "http://example.com/?q={searchTerms}");
+  Services.search.addEngineWithDetails("Engine2", "", "", "", "GET",
+    "http://example.com/?q={searchTerms}");
+
+  let tabOptions = {
+    gBrowser,
+    url: testURL
+  };
+
+  let UIStringsResponseAck = new Promise(resolve => {
+    NewTabWebChannel.once("UIStringsAck", (_, msg) => {
+      ok(true, "a search request response for UI string has been received");
+      ok(msg.data, "received the UI Strings");
+      resolve();
+    });
+  });
+  let suggestionsResponseAck = new Promise(resolve => {
+    NewTabWebChannel.once("suggestionsAck", (_, msg) => {
+      ok(true, "a search request response for suggestions has been received");
+      ok(msg.data, "received the suggestions");
+      resolve();
+    });
+  });
+  let stateResponseAck = new Promise(resolve => {
+    NewTabWebChannel.once("stateAck", (_, msg) => {
+      ok(true, "a search request response for state has been received");
+      ok(msg.data, "received a state object");
+      resolve();
+    });
+  });
+  let currentEngineResponseAck = new Promise(resolve => {
+    NewTabWebChannel.once("currentEngineAck", (_, msg) => {
+      ok(true, "a search request response for current engine has been received");
+      ok(msg.data, "received a current engine");
+      resolve();
+    });
+  });
+
+  yield BrowserTestUtils.withNewTab(tabOptions, function*() {
+    yield UIStringsResponseAck;
+    yield suggestionsResponseAck;
+    yield stateResponseAck;
+    yield currentEngineResponseAck;
+  });
+
+  cleanup();
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/tests/browser/newtabmessages_search.html
@@ -0,0 +1,113 @@
+<html>
+  <head>
+    <meta charset="utf8">
+    <title>Newtab WebChannel test</title>
+  </head>
+  <body>
+    <script>
+      let suggestionsData = {
+        engineName: "Engine1",
+        searchString: "test",
+      };
+      let removeFormHistoryData = "test";
+      let performSearchData = {
+        engineName: "Engine1",
+        healthReportKey: "1",
+        searchPurpose: "d",
+        searchString: "test",
+      };
+      let cycleEngineData = "Engine2";
+
+      window.addEventListener("WebChannelMessageToContent", function(e) {
+        if (e.detail.message) {
+          let reply;
+          switch (e.detail.message.type) {
+            case "RECEIVE_UISTRINGS":
+              reply = new window.CustomEvent("WebChannelMessageToChrome", {
+                detail: {
+                  id: "newtab",
+                  message: JSON.stringify({type: "UIStringsAck", data: e.detail.message.data}),
+                }
+              });
+              window.dispatchEvent(reply);
+              break;
+            case "RECEIVE_SEARCH_SUGGESTIONS":
+              reply = new window.CustomEvent("WebChannelMessageToChrome", {
+                detail: {
+                  id: "newtab",
+                  message: JSON.stringify({type: "suggestionsAck", data: e.detail.message.data}),
+                }
+              });
+              window.dispatchEvent(reply);
+              break;
+            case "RECEIVE_SEARCH_STATE":
+              reply = new window.CustomEvent("WebChannelMessageToChrome", {
+                detail: {
+                  id: "newtab",
+                  message: JSON.stringify({type: "stateAck", data: e.detail.message.data}),
+                }
+              });
+              window.dispatchEvent(reply);
+              break;
+            case "RECEIVE_CURRENT_ENGINE":
+              reply = new window.CustomEvent("WebChannelMessageToChrome", {
+                detail: {
+                  id: "newtab",
+                  message: JSON.stringify({type: "currentEngineAck", data: e.detail.message.data}),
+                }
+              });
+              window.dispatchEvent(reply);
+              break;
+          }
+        }
+      }, true);
+
+      document.onreadystatechange = function () {
+        if (document.readyState === "complete") {
+          let msg = new window.CustomEvent("WebChannelMessageToChrome", {
+            detail: {
+              id: "newtab",
+              message: JSON.stringify({type: "REQUEST_UISTRINGS"}),
+            }
+          });
+          window.dispatchEvent(msg);
+          msg = new window.CustomEvent("WebChannelMessageToChrome", {
+            detail: {
+              id: "newtab",
+              message: JSON.stringify({type: "REQUEST_SEARCH_SUGGESTIONS", data: suggestionsData}),
+            }
+          });
+          window.dispatchEvent(msg);
+          msg = new window.CustomEvent("WebChannelMessageToChrome", {
+            detail: {
+              id: "newtab",
+              message: JSON.stringify({type: "REQUEST_SEARCH_STATE"}),
+            }
+          });
+          window.dispatchEvent(msg);
+          msg = new window.CustomEvent("WebChannelMessageToChrome", {
+            detail: {
+              id: "newtab",
+              message: JSON.stringify({type: "REQUEST_REMOVE_FORM_HISTORY", data: removeFormHistoryData}),
+            }
+          });
+          window.dispatchEvent(msg);
+          msg = new window.CustomEvent("WebChannelMessageToChrome", {
+            detail: {
+              id: "newtab",
+              message: JSON.stringify({type: "REQUEST_PERFORM_SEARCH", data: performSearchData}),
+            }
+          });
+          window.dispatchEvent(msg);
+          msg = new window.CustomEvent("WebChannelMessageToChrome", {
+            detail: {
+              id: "newtab",
+              message: JSON.stringify({type: "REQUEST_CYCLE_ENGINE", data: cycleEngineData}),
+            }
+          });
+          window.dispatchEvent(msg);
+        }
+      }
+    </script>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/tests/xpcshell/test_NewTabSearchProvider.js
@@ -0,0 +1,84 @@
+"use strict";
+
+/* global XPCOMUtils, NewTabSearchProvider, run_next_test, ok, equal, do_check_true, do_get_profile, Services */
+/* exported run_test */
+/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
+
+const Cu = Components.utils;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "NewTabSearchProvider",
+    "resource:///modules/NewTabSearchProvider.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ContentSearch",
+                                  "resource:///modules/ContentSearch.jsm");
+
+// ensure a profile exists
+do_get_profile();
+
+function run_test() {
+  run_next_test();
+}
+
+function hasProp(obj) {
+  return function(aProp) {
+    ok(obj.hasOwnProperty(aProp), `expect to have property ${aProp}`);
+  };
+}
+
+add_task(function* test_search() {
+  ContentSearch.init();
+  let observerPromise = new Promise(resolve => {
+    Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+      if (aData === "init-complete" && aTopic === "browser-search-service") {
+        Services.obs.removeObserver(observer, "browser-search-service");
+        resolve();
+      }
+    }, "browser-search-service", false);
+  });
+  Services.search.init();
+  yield observerPromise;
+  do_check_true(Services.search.isInitialized);
+
+  // get initial state of search and check it has correct properties
+  let state = yield NewTabSearchProvider.search.asyncGetState();
+  let stateProps = hasProp(state);
+  ["engines", "currentEngine"].forEach(stateProps);
+
+  // check that the current engine is correct and has correct properties
+  let {currentEngine} = state;
+  equal(currentEngine.name, Services.search.currentEngine.name, "Current engine has been correctly set");
+  var engineProps = hasProp(currentEngine);
+  ["name", "placeholder", "iconBuffer"].forEach(engineProps);
+
+  //create dummy test engines to test observer
+  Services.search.addEngineWithDetails("TestSearch1", "", "", "", "GET",
+    "http://example.com/?q={searchTerms}");
+  Services.search.addEngineWithDetails("TestSearch2", "", "", "", "GET",
+    "http://example.com/?q={searchTerms}");
+
+  // set one of the dummy test engines to the default engine
+  Services.search.defaultEngine = Services.search.getEngineByName("TestSearch1");
+
+  // test that the event emitter is working by setting a new current engine "TestSearch2"
+  let engineName = "TestSearch2";
+  NewTabSearchProvider.search.init();
+
+  // event emitter will fire when current engine is changed
+  let promise = new Promise(resolve => {
+    NewTabSearchProvider.search.once("browser-search-engine-modified", (name, data) => { // jshint ignore:line
+      resolve([name, data.name]);
+    });
+  });
+
+  // set a new current engine
+  Services.search.currentEngine = Services.search.getEngineByName(engineName);
+  let expectedEngineName = Services.search.currentEngine.name;
+
+  // emitter should fire and return the new engine
+  let [eventName, actualEngineName] = yield promise;
+  equal(eventName, "browser-search-engine-modified", `emitter sent the correct event ${eventName}`);
+  equal(expectedEngineName, actualEngineName, `emitter set the correct engine ${expectedEngineName}`);
+  NewTabSearchProvider.search.uninit();
+});
+
--- a/browser/components/newtab/tests/xpcshell/xpcshell.ini
+++ b/browser/components/newtab/tests/xpcshell/xpcshell.ini
@@ -1,10 +1,11 @@
 [DEFAULT]
 head =
 tail =
 firefox-appdir = browser
 skip-if = toolkit == 'android' || toolkit == 'gonk'
 
 [test_AboutNewTabService.js]
 [test_NewTabPrefsProvider.js]
+[test_NewTabSearchProvider.js]
 [test_NewTabURL.js]
 [test_PlacesProvider.js]
--- a/browser/modules/ContentSearch.jsm
+++ b/browser/modules/ContentSearch.jsm
@@ -1,12 +1,12 @@
 /* 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/. */
-
+/* globals XPCOMUtils, Services, Task, Promise, SearchSuggestionController, FormHistory, PrivateBrowsingUtils */
 "use strict";
 
 this.EXPORTED_SYMBOLS = [
   "ContentSearch",
 ];
 
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
@@ -68,20 +68,20 @@ const MAX_SUGGESTIONS = 6;
  *
  * Outbound messages have the following types:
  *
  *   CurrentEngine
  *     Broadcast when the current engine changes.
  *     data: see _currentEngineObj
  *   CurrentState
  *     Broadcast when the current search state changes.
- *     data: see _currentStateObj
+ *     data: see currentStateObj
  *   State
  *     Sent in reply to GetState.
- *     data: see _currentStateObj
+ *     data: see currentStateObj
  *   Strings
  *     Sent in reply to GetStrings
  *     data: Object containing string names and values for the current locale.
  *   Suggestions
  *     Sent in reply to GetSuggestions.
  *     data: see _onMessageGetSuggestions
  *   SuggestionsCancelled
  *     Sent in reply to GetSuggestions when pending GetSuggestions events are
@@ -121,16 +121,17 @@ this.ContentSearch = {
   get searchSuggestionUIStrings() {
     if (this._searchSuggestionUIStrings) {
       return this._searchSuggestionUIStrings;
     }
     this._searchSuggestionUIStrings = {};
     let searchBundle = Services.strings.createBundle("chrome://browser/locale/search.properties");
     let stringNames = ["searchHeader", "searchPlaceholder", "searchForSomethingWith",
                        "searchWithHeader", "searchSettings"];
+
     for (let name of stringNames) {
       this._searchSuggestionUIStrings[name] = searchBundle.GetStringFromName(name);
     }
     return this._searchSuggestionUIStrings;
   },
 
   destroy: function () {
     if (this._destroyedPromise) {
@@ -139,17 +140,18 @@ this.ContentSearch = {
 
     Cc["@mozilla.org/globalmessagemanager;1"].
       getService(Ci.nsIMessageListenerManager).
       removeMessageListener(INBOUND_MESSAGE, this);
     Services.obs.removeObserver(this, "browser-search-engine-modified");
     Services.obs.removeObserver(this, "shutdown-leaks-before-check");
 
     this._eventQueue.length = 0;
-    return this._destroyedPromise = Promise.resolve(this._currentEventPromise);
+    this._destroyedPromise = Promise.resolve(this._currentEventPromise);
+    return this._destroyedPromise;
   },
 
   /**
    * Focuses the search input in the page with the given message manager.
    * @param  messageManager
    *         The MessageManager object of the selected browser.
    */
   focusInput: function (messageManager) {
@@ -172,17 +174,17 @@ this.ContentSearch = {
       msg.target.removeEventListener("SwapDocShells", msg, true);
       msg.target = event.detail;
       msg.target.addEventListener("SwapDocShells", msg, true);
     };
     msg.target.addEventListener("SwapDocShells", msg, true);
 
     // Search requests cause cancellation of all Suggestion requests from the
     // same browser.
-    if (msg.data.type == "Search") {
+    if (msg.data.type === "Search") {
       this._cancelSuggestions(msg);
     }
 
     this._eventQueue.push({
       type: "Message",
       data: msg,
     });
     this._processEventQueue();
@@ -200,16 +202,165 @@ this.ContentSearch = {
       break;
     case "shutdown-leaks-before-check":
       subj.wrappedJSObject.client.addBlocker(
         "ContentSearch: Wait until the service is destroyed", () => this.destroy());
       break;
     }
   },
 
+  removeFormHistoryEntry: function (msg, entry) {
+    let browserData = this._suggestionDataForBrowser(msg.target);
+    if (browserData && browserData.previousFormHistoryResult) {
+      let { previousFormHistoryResult } = browserData;
+      for (let i = 0; i < previousFormHistoryResult.matchCount; i++) {
+        if (previousFormHistoryResult.getValueAt(i) === entry) {
+          previousFormHistoryResult.removeValueAt(i, true);
+          break;
+        }
+      }
+    }
+  },
+
+  performSearch: function (msg, data) {
+    this._ensureDataHasProperties(data, [
+      "engineName",
+      "searchString",
+      "healthReportKey",
+      "searchPurpose",
+    ]);
+    let engine = Services.search.getEngineByName(data.engineName);
+    let submission = engine.getSubmission(data.searchString, "", data.searchPurpose);
+    let browser = msg.target;
+    let win;
+    try {
+      win = browser.ownerDocument.defaultView;
+    }
+    catch (err) {
+      // The browser may have been closed between the time its content sent the
+      // message and the time we handle it.  In that case, trying to call any
+      // method on it will throw.
+      return;
+    }
+
+    let where = win.whereToOpenLink(data.originalEvent);
+
+    // There is a chance that by the time we receive the search message, the user
+    // has switched away from the tab that triggered the search. If, based on the
+    // event, we need to load the search in the same tab that triggered it (i.e.
+    // where === "current"), openUILinkIn will not work because that tab is no
+    // longer the current one. For this case we manually load the URI.
+    if (where === "current") {
+      browser.loadURIWithFlags(submission.uri.spec,
+                               Ci.nsIWebNavigation.LOAD_FLAGS_NONE, null, null,
+                               submission.postData);
+    } else {
+      let params = {
+        postData: submission.postData,
+        inBackground: Services.prefs.getBoolPref("browser.tabs.loadInBackground"),
+      };
+      win.openUILinkIn(submission.uri.spec, where, params);
+    }
+    win.BrowserSearch.recordSearchInTelemetry(engine, data.healthReportKey,
+                                              data.selection || null);
+    return;
+  },
+
+  getSuggestions: Task.async(function* (engineName, searchString, browser, remoteTimeout=null) {
+    let engine = Services.search.getEngineByName(engineName);
+    if (!engine) {
+      throw new Error("Unknown engine name: " + engineName);
+    }
+
+    let browserData = this._suggestionDataForBrowser(browser, true);
+    let { controller } = browserData;
+    let ok = SearchSuggestionController.engineOffersSuggestions(engine);
+    controller.maxLocalResults = ok ? MAX_LOCAL_SUGGESTIONS : MAX_SUGGESTIONS;
+    controller.maxRemoteResults = ok ? MAX_SUGGESTIONS : 0;
+    controller.remoteTimeout = remoteTimeout || undefined;
+    let priv = PrivateBrowsingUtils.isBrowserPrivate(browser);
+    // fetch() rejects its promise if there's a pending request, but since we
+    // process our event queue serially, there's never a pending request.
+    this._currentSuggestion = { controller: controller, target: browser };
+    let suggestions = yield controller.fetch(searchString, priv, engine);
+    this._currentSuggestion = null;
+
+    // suggestions will be null if the request was cancelled
+    let result = {};
+    if (!suggestions) {
+      return result;
+    }
+
+    // Keep the form history result so RemoveFormHistoryEntry can remove entries
+    // from it.  Keeping only one result isn't foolproof because the client may
+    // try to remove an entry from one set of suggestions after it has requested
+    // more but before it's received them.  In that case, the entry may not
+    // appear in the new suggestions.  But that should happen rarely.
+    browserData.previousFormHistoryResult = suggestions.formHistoryResult;
+    result = {
+      engineName,
+      term: suggestions.term,
+      local: suggestions.local,
+      remote: suggestions.remote,
+    };
+    return result;
+  }),
+
+  addFormHistoryEntry: Task.async(function* (browser, entry="") {
+    let isPrivate = false;
+    try {
+      // isBrowserPrivate assumes that the passed-in browser has all the normal
+      // properties, which won't be true if the browser has been destroyed.
+      // That may be the case here due to the asynchronous nature of messaging.
+      isPrivate = PrivateBrowsingUtils.isBrowserPrivate(browser.target);
+    } catch (err) {
+      return false;
+    }
+    if (isPrivate || entry === "") {
+      return false;
+    }
+    let browserData = this._suggestionDataForBrowser(browser.target, true);
+    FormHistory.update({
+      op: "bump",
+      fieldname: browserData.controller.formHistoryParam,
+      value: entry,
+    }, {
+      handleCompletion: () => {},
+      handleError: err => {
+        Cu.reportError("Error adding form history entry: " + err);
+      },
+    });
+    return true;
+  }),
+
+  currentStateObj: Task.async(function* (uriFlag=false) {
+    let state = {
+      engines: [],
+      currentEngine: yield this._currentEngineObj(),
+    };
+    if (uriFlag) {
+      state.currentEngine.iconBuffer = Services.search.currentEngine.getIconURLBySize(16, 16);
+    }
+    let pref = Services.prefs.getCharPref("browser.search.hiddenOneOffs");
+    let hiddenList = pref ? pref.split(",") : [];
+    for (let engine of Services.search.getVisibleEngines()) {
+      let uri = engine.getIconURLBySize(16, 16);
+      let iconBuffer = uri;
+      if (!uriFlag) {
+        iconBuffer = yield this._arrayBufferFromDataURI(uri);
+      }
+      state.engines.push({
+        name: engine.name,
+        iconBuffer,
+        hidden: hiddenList.indexOf(engine.name) !== -1,
+      });
+    }
+    return state;
+  }),
+
   _processEventQueue: function () {
     if (this._currentEventPromise || !this._eventQueue.length) {
       return;
     }
 
     let event = this._eventQueue.shift();
 
     this._currentEventPromise = Task.spawn(function* () {
@@ -222,24 +373,24 @@ this.ContentSearch = {
         this._processEventQueue();
       }
     }.bind(this));
   },
 
   _cancelSuggestions: function (msg) {
     let cancelled = false;
     // cancel active suggestion request
-    if (this._currentSuggestion && this._currentSuggestion.target == msg.target) {
+    if (this._currentSuggestion && this._currentSuggestion.target === msg.target) {
       this._currentSuggestion.controller.stop();
       cancelled = true;
     }
     // cancel queued suggestion requests
     for (let i = 0; i < this._eventQueue.length; i++) {
       let m = this._eventQueue[i].data;
-      if (msg.target == m.target && m.data.type == "GetSuggestions") {
+      if (msg.target === m.target && m.data.type === "GetSuggestions") {
         this._eventQueue.splice(i, 1);
         cancelled = true;
         i--;
       }
     }
     if (cancelled) {
       this._reply(msg, "SuggestionsCancelled");
     }
@@ -250,186 +401,83 @@ this.ContentSearch = {
     if (methodName in this) {
       yield this._initService();
       yield this[methodName](msg, msg.data.data);
       msg.target.removeEventListener("SwapDocShells", msg, true);
     }
   }),
 
   _onMessageGetState: function (msg, data) {
-    return this._currentStateObj().then(state => {
+    return this.currentStateObj().then(state => {
       this._reply(msg, "State", state);
     });
   },
 
   _onMessageGetStrings: function (msg, data) {
     this._reply(msg, "Strings", this.searchSuggestionUIStrings);
   },
 
   _onMessageSearch: function (msg, data) {
-    this._ensureDataHasProperties(data, [
-      "engineName",
-      "searchString",
-      "healthReportKey",
-      "searchPurpose",
-    ]);
-    let engine = Services.search.getEngineByName(data.engineName);
-    let submission = engine.getSubmission(data.searchString, "", data.searchPurpose);
-    let browser = msg.target;
-    let win;
-    try {
-      win = browser.ownerDocument.defaultView;
-    }
-    catch (err) {
-      // The browser may have been closed between the time its content sent the
-      // message and the time we handle it.  In that case, trying to call any
-      // method on it will throw.
-      return Promise.resolve();
-    }
-
-    let where = win.whereToOpenLink(data.originalEvent);
-
-    // There is a chance that by the time we receive the search message, the user
-    // has switched away from the tab that triggered the search. If, based on the
-    // event, we need to load the search in the same tab that triggered it (i.e.
-    // where == "current"), openUILinkIn will not work because that tab is no
-    // longer the current one. For this case we manually load the URI.
-    if (where == "current") {
-      browser.loadURIWithFlags(submission.uri.spec,
-                               Ci.nsIWebNavigation.LOAD_FLAGS_NONE, null, null,
-                               submission.postData);
-    } else {
-      let params = {
-        postData: submission.postData,
-        inBackground: Services.prefs.getBoolPref("browser.tabs.loadInBackground"),
-      };
-      win.openUILinkIn(submission.uri.spec, where, params);
-    }
-    win.BrowserSearch.recordSearchInTelemetry(engine, data.healthReportKey,
-                                              data.selection || null);
-    return Promise.resolve();
+    this.performSearch(msg, data);
   },
 
   _onMessageSetCurrentEngine: function (msg, data) {
     Services.search.currentEngine = Services.search.getEngineByName(data);
-    return Promise.resolve();
   },
 
   _onMessageManageEngines: function (msg, data) {
     let browserWin = msg.target.ownerDocument.defaultView;
     browserWin.openPreferences("paneSearch");
-    return Promise.resolve();
   },
 
   _onMessageGetSuggestions: Task.async(function* (msg, data) {
     this._ensureDataHasProperties(data, [
       "engineName",
       "searchString",
     ]);
-
-    let engine = Services.search.getEngineByName(data.engineName);
-    if (!engine) {
-      throw new Error("Unknown engine name: " + data.engineName);
-    }
-
-    let browserData = this._suggestionDataForBrowser(msg.target, true);
-    let { controller } = browserData;
-    let ok = SearchSuggestionController.engineOffersSuggestions(engine);
-    controller.maxLocalResults = ok ? MAX_LOCAL_SUGGESTIONS : MAX_SUGGESTIONS;
-    controller.maxRemoteResults = ok ? MAX_SUGGESTIONS : 0;
-    controller.remoteTimeout = data.remoteTimeout || undefined;
-    let priv = PrivateBrowsingUtils.isBrowserPrivate(msg.target);
-    // fetch() rejects its promise if there's a pending request, but since we
-    // process our event queue serially, there's never a pending request.
-    this._currentSuggestion = { controller: controller, target: msg.target };
-    let suggestions = yield controller.fetch(data.searchString, priv, engine);
-    this._currentSuggestion = null;
-
-    // suggestions will be null if the request was cancelled
-    if (!suggestions) {
-      return;
-    }
-
-    // Keep the form history result so RemoveFormHistoryEntry can remove entries
-    // from it.  Keeping only one result isn't foolproof because the client may
-    // try to remove an entry from one set of suggestions after it has requested
-    // more but before it's received them.  In that case, the entry may not
-    // appear in the new suggestions.  But that should happen rarely.
-    browserData.previousFormHistoryResult = suggestions.formHistoryResult;
+    let {engineName, searchString} = data;
+    let suggestions = yield this.getSuggestions(engineName, searchString, msg.target);
 
     this._reply(msg, "Suggestions", {
       engineName: data.engineName,
       searchString: suggestions.term,
       formHistory: suggestions.local,
       remote: suggestions.remote,
     });
   }),
 
-  _onMessageAddFormHistoryEntry: function (msg, entry) {
-    let isPrivate = true;
-    try {
-      // isBrowserPrivate assumes that the passed-in browser has all the normal
-      // properties, which won't be true if the browser has been destroyed.
-      // That may be the case here due to the asynchronous nature of messaging.
-      isPrivate = PrivateBrowsingUtils.isBrowserPrivate(msg.target);
-    } catch (err) {}
-    if (isPrivate || entry === "") {
-      return Promise.resolve();
-    }
-    let browserData = this._suggestionDataForBrowser(msg.target, true);
-    if (FormHistory.enabled) {
-      FormHistory.update({
-        op: "bump",
-        fieldname: browserData.controller.formHistoryParam,
-        value: entry,
-      }, {
-        handleCompletion: () => {},
-        handleError: err => {
-          Cu.reportError("Error adding form history entry: " + err);
-        },
-      });
-    }
-    return Promise.resolve();
-  },
+  _onMessageAddFormHistoryEntry: Task.async(function* (msg, entry) {
+    yield this.addFormHistoryEntry(msg, entry);
+  }),
 
   _onMessageRemoveFormHistoryEntry: function (msg, entry) {
-    let browserData = this._suggestionDataForBrowser(msg.target);
-    if (browserData && browserData.previousFormHistoryResult) {
-      let { previousFormHistoryResult } = browserData;
-      for (let i = 0; i < previousFormHistoryResult.matchCount; i++) {
-        if (previousFormHistoryResult.getValueAt(i) == entry) {
-          previousFormHistoryResult.removeValueAt(i, true);
-          break;
-        }
-      }
-    }
-    return Promise.resolve();
+    this.removeFormHistoryEntry(msg, entry);
   },
 
   _onMessageSpeculativeConnect: function (msg, engineName) {
     let engine = Services.search.getEngineByName(engineName);
     if (!engine) {
       throw new Error("Unknown engine name: " + engineName);
     }
     if (msg.target.contentWindow) {
       engine.speculativeConnect({
         window: msg.target.contentWindow,
       });
     }
   },
 
   _onObserve: Task.async(function* (data) {
-    if (data == "engine-current") {
+    if (data === "engine-current") {
       let engine = yield this._currentEngineObj();
       this._broadcast("CurrentEngine", engine);
     }
-    else if (data != "engine-default") {
+    else if (data !== "engine-default") {
       // engine-default is always sent with engine-current and isn't otherwise
       // relevant to content searches.
-      let state = yield this._currentStateObj();
+      let state = yield this.currentStateObj();
       this._broadcast("CurrentState", state);
     }
   }),
 
   _suggestionDataForBrowser: function (browser, create=false) {
     let data = this._suggestionMap.get(browser);
     if (!data && create) {
       // Since one SearchSuggestionController instance is meant to be used per
@@ -459,34 +507,16 @@ this.ContentSearch = {
 
   _msgArgs: function (type, data) {
     return [OUTBOUND_MESSAGE, {
       type: type,
       data: data,
     }];
   },
 
-  _currentStateObj: Task.async(function* () {
-    let state = {
-      engines: [],
-      currentEngine: yield this._currentEngineObj(),
-    };
-    let pref = Services.prefs.getCharPref("browser.search.hiddenOneOffs");
-    let hiddenList = pref ? pref.split(",") : [];
-    for (let engine of Services.search.getVisibleEngines()) {
-      let uri = engine.getIconURLBySize(16, 16);
-      state.engines.push({
-        name: engine.name,
-        iconBuffer: yield this._arrayBufferFromDataURI(uri),
-        hidden: hiddenList.indexOf(engine.name) != -1,
-      });
-    }
-    return state;
-  }),
-
   _currentEngineObj: Task.async(function* () {
     let engine = Services.search.currentEngine;
     let favicon = engine.getIconURLBySize(16, 16);
     let placeholder = this._stringBundle.formatStringFromName(
       "searchWithEngine", [engine.name], 1);
     let obj = {
       name: engine.name,
       placeholder: placeholder,