Bug 1262916 - Option to add custom search engine should disappear when site is added, r=margaret, florian
authorMark Capella <markcapella@twcny.rr.com>
Sat, 14 May 2016 16:43:51 -0400
changeset 336478 c67242e935ee610b6f6a6e37596a274bc9ed2c9f
parent 336464 403912ca555eb65f814b18ecf38ad8e8e98569f5
child 336479 091bc7c572dbbfcb751c8e0d9c37b36ff0a83a6a
push id6249
push userjlund@mozilla.com
push dateMon, 01 Aug 2016 13:59:36 +0000
treeherdermozilla-beta@bad9d4f5bf7e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmargaret, florian
bugs1262916
milestone49.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1262916 - Option to add custom search engine should disappear when site is added, r=margaret, florian
mobile/android/base/java/org/mozilla/gecko/ActionBarTextSelection.java
mobile/android/base/java/org/mozilla/gecko/text/FloatingToolbarTextSelection.java
mobile/android/chrome/content/ActionBarHandler.js
mobile/android/chrome/content/browser.js
netwerk/base/nsIBrowserSearchService.idl
toolkit/components/search/nsSearchService.js
toolkit/components/search/tests/xpcshell/test_hasEngineWithURL.js
toolkit/components/search/tests/xpcshell/xpcshell.ini
--- a/mobile/android/base/java/org/mozilla/gecko/ActionBarTextSelection.java
+++ b/mobile/android/base/java/org/mozilla/gecko/ActionBarTextSelection.java
@@ -218,16 +218,21 @@ class ActionBarTextSelection extends Lay
 
                         selectionID = message.getString("selectionID");
                         mCurrentItems = null;
                         if (mActionModeTimerTask != null) {
                             mActionModeTimerTask.cancel();
                         }
 
                     } else if (event.equals("TextSelection:ActionbarStatus")) {
+                        // Ensure async updates from SearchService for example are valid.
+                        if (selectionID != message.optString("selectionID")) {
+                            return;
+                        }
+
                         // Update the actionBar actions as provided by Gecko.
                         showActionMode(message.getJSONArray("actions"));
 
                     } else if (event.equals("TextSelection:ActionbarUninit")) {
                         // Uninit the actionbar. Schedule a cancellable close
                         // action to avoid UI jank. (During SelectionAll for ex).
                         mCurrentItems = null;
                         mActionModeTimerTask = new ActionModeTimerTask();
--- a/mobile/android/base/java/org/mozilla/gecko/text/FloatingToolbarTextSelection.java
+++ b/mobile/android/base/java/org/mozilla/gecko/text/FloatingToolbarTextSelection.java
@@ -116,16 +116,21 @@ public class FloatingToolbarTextSelectio
 
     private void handleOnMainThread(final String event, final JSONObject message) {
         if ("TextSelection:ActionbarInit".equals(event)) {
             Telemetry.sendUIEvent(TelemetryContract.Event.SHOW,
                 TelemetryContract.Method.CONTENT, "text_selection");
 
             selectionID = message.optString("selectionID");
         } else if ("TextSelection:ActionbarStatus".equals(event)) {
+            // Ensure async updates from SearchService for example are valid.
+            if (selectionID != message.optString("selectionID")) {
+                return;
+            }
+
             updateRect(message);
 
             if (!isRectVisible()) {
                 finishActionMode();
             } else {
                 startActionMode(TextAction.fromEventMessage(message));
             }
         } else if ("TextSelection:ActionbarUninit".equals(event)) {
--- a/mobile/android/chrome/content/ActionBarHandler.js
+++ b/mobile/android/chrome/content/ActionBarHandler.js
@@ -17,16 +17,18 @@ var ActionBarHandler = {
   // Error codes returned from _init().
   START_TOUCH_ERROR: {
     NO_CONTENT_WINDOW: "No valid content Window found.",
     NONE: "",
   },
 
   _nextSelectionID: 1, // Next available.
   _selectionID: null, // Unique Selection ID, assigned each time we _init().
+
+  _boundingClientRect: null, // Current selections boundingClientRect.
   _actionBarActions: null, // Most-recent set of actions sent to ActionBar.
 
   /**
    * Receive and act on AccessibleCarets caret state-change
    * (mozcaretstatechanged) events.
    */
   caretStateChangedHandler: function(e) {
     // Close an open ActionBar, if carets no longer logically visible.
@@ -62,20 +64,23 @@ var ActionBarHandler = {
     }
 
     // Else, update an open ActionBar.
     if (this._selectionID) {
       if (!this._selectionHasChanged()) {
         // Still the same active selection.
         if (e.reason == 'visibilitychange' || e.reason == 'presscaret' ||
             e.reason == 'scroll' ) {
+          // Visibility changes don't affect boundingClientRect.
           this._updateVisibility();
         } else {
+          // Selection changes update boundingClientRect.
+          this._boundingClientRect = e.boundingClientRect;
           let forceUpdate = e.reason == 'updateposition' || e.reason == 'releasecaret';
-          this._sendActionBarActions(forceUpdate, e.boundingClientRect);
+          this._sendActionBarActions(forceUpdate);
         }
       } else {
         // We've started a new selection entirely.
         this._uninit(false);
         this._init(e.boundingClientRect);
       }
     }
   },
@@ -130,23 +135,24 @@ var ActionBarHandler = {
     let [element, win] = this._getSelectionTargets();
     if (!win) {
       return this.START_TOUCH_ERROR.NO_CONTENT_WINDOW;
     }
 
     // Hold the ActionBar ID provided by Gecko.
     this._selectionID = this._nextSelectionID++;
     [this._targetElement, this._contentWindow] = [element, win];
+    this._boundingClientRect = boundingClientRect;
 
     // Open the ActionBar, send it's actions list.
     Messaging.sendRequest({
       type: "TextSelection:ActionbarInit",
       selectionID: this._selectionID,
     });
-    this._sendActionBarActions(true, boundingClientRect);
+    this._sendActionBarActions(true);
 
     return this.START_TOUCH_ERROR.NONE;
   },
 
   /**
    * Called when content is scrolled and handles are hidden.
    */
   _updateVisibility: function() {
@@ -204,16 +210,17 @@ var ActionBarHandler = {
     Messaging.sendRequest({
       type: "TextSelection:ActionbarUninit",
     });
 
     // Clear the selection ID to complete the uninit(), but leave our reference
     // to selectionTargets (_targetElement, _contentWindow) in case we need
     // a final clearSelection().
     this._selectionID = null;
+    this._boundingClientRect = null;
 
     // Clear selection required if triggered by self, or TextSelection icon
     // actions. If called by Gecko CaretStateChangedEvent,
     // visibility state is already correct.
     if (clearSelection) {
       this._clearSelection();
     }
   },
@@ -243,34 +250,36 @@ var ActionBarHandler = {
    * Called to determine current ActionBar actions and send to TextSelection
    * handler. By default we only send if current action state differs from
    * the previous.
    * @param By default we only send an ActionBarStatus update message if
    *        there is a change from the previous state. sendAlways can be
    *        set by init() for example, where we want to always send the
    *        current state.
    */
-  _sendActionBarActions: function(sendAlways, boundingClientRect) {
+  _sendActionBarActions: function(sendAlways) {
     let actions = this._getActionBarActions();
+
     let actionCountUnchanged = this._actionBarActions &&
       actions.length === this._actionBarActions.length;
     let actionsMatch = actionCountUnchanged &&
       this._actionBarActions.every((e,i) => {
         return e.id === actions[i].id;
       });
 
     if (sendAlways || !actionsMatch) {
       Messaging.sendRequest({
         type: "TextSelection:ActionbarStatus",
+        selectionID: this._selectionID,
         actions: actions,
-        x: boundingClientRect.x,
-        y: boundingClientRect.y,
-        width: boundingClientRect.width,
-        height: boundingClientRect.height
-      });;
+        x: this._boundingClientRect.x,
+        y: this._boundingClientRect.y,
+        width: this._boundingClientRect.width,
+        height: this._boundingClientRect.height
+      });
     }
 
     this._actionBarActions = actions;
   },
 
   /**
    * Determine and return current ActionBar state.
    */
@@ -530,25 +539,42 @@ var ActionBarHandler = {
         matches: function(element, win) {
           if(!(element instanceof HTMLInputElement)) {
             return false;
           }
           let form = element.form;
           if (!form || element.type == "password") {
             return false;
           }
+
           let method = form.method.toUpperCase();
-          return (method == "GET" || method == "") ||
-                 (form.enctype != "text/plain") && (form.enctype != "multipart/form-data");
+          let canAddEngine = (method == "GET") ||
+            (method == "POST" && (form.enctype != "text/plain" && form.enctype != "multipart/form-data"));
+          if (!canAddEngine) {
+            return false;
+          }
+
+          // If SearchEngine query finds it, then we don't want action to add displayed.
+          if (SearchEngines.visibleEngineExists(element)) {
+            return false;
+          }
+
+          return true;
         },
       },
 
       action: function(element, win) {
         UITelemetry.addEvent("action.1", "actionbar", null, "add_search_engine");
-        SearchEngines.addEngine(element);
+
+        // Engines are added asynch. If required, update SelectionUI on callback.
+        SearchEngines.addEngine(element, (result) => {
+          if (result) {
+            ActionBarHandler._sendActionBarActions(true);
+          }
+        });
       },
     },
 
     SHARE: {
       id: "share_action",
       label: Strings.browser.GetStringFromName("contextmenu.share"),
       icon: "drawable://ic_menu_share",
       order: 0,
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -6691,100 +6691,164 @@ var SearchEngines = {
           errorMessage = "alertSearchEngineErrorToast";
         }
 
         Snackbars.show(Strings.browser.formatStringFromName(errorMessage, [engine.title], 1), Snackbars.LENGTH_LONG);
       }
     });
   },
 
-  addEngine: function addEngine(aElement) {
+  /**
+   * Build and return an array of sorted form data / Query Parameters
+   * for an element in a submission form.
+   *
+   * @param element
+   *        A valid submission element of a form.
+   */
+  _getSortedFormData: function(element) {
+    let formData = [];
+
+    for (let formElement of element.form.elements) {
+      if (!formElement.type) {
+        continue;
+      }
+
+      // Make this text field a generic search parameter.
+      if (element == formElement) {
+        formData.push({ name: formElement.name, value: "{searchTerms}" });
+        continue;
+      }
+
+      // Add other form elements as parameters.
+      switch (formElement.type.toLowerCase()) {
+        case "checkbox":
+        case "radio":
+          if (!formElement.checked) {
+            break;
+          }
+        case "text":
+        case "hidden":
+        case "textarea":
+          formData.push({ name: escape(formElement.name), value: escape(formElement.value) });
+          break;
+
+        case "select-one": {
+          for (let option of formElement.options) {
+            if (option.selected) {
+              formData.push({ name: escape(formElement.name), value: escape(formElement.value) });
+              break;
+            }
+          }
+        }
+      }
+    };
+
+    // Return valid, pre-sorted queryParams. 
+    return formData.filter(a => a.name && a.value).sort((a, b) => {
+      // nsIBrowserSearchService.hasEngineWithURL() ensures sort, but this helps.
+      if (a.name > b.name) {
+        return 1;
+      }
+      if (b.name > a.name) {
+        return -1;
+      }
+
+      if (a.value > b.value) {
+        return 1;
+      }
+      if (b.value > a.value) {
+        return -1;
+      }
+
+      return 0;
+    });
+  },
+
+  /**
+   * Check if any search engines already handle an EngineURL of type
+   * URLTYPE_SEARCH_HTML, matching this request-method, formURL, and queryParams.
+   */
+  visibleEngineExists: function(element) {
+    let formData = this._getSortedFormData(element);
+
+    let form = element.form;
+    let method = form.method.toUpperCase();
+
+    let charset = element.ownerDocument.characterSet;
+    let docURI = Services.io.newURI(element.ownerDocument.URL, charset, null);
+    let formURL = Services.io.newURI(form.getAttribute("action"), charset, docURI).spec;
+
+    return Services.search.hasEngineWithURL(method, formURL, formData);
+  },
+
+  /**
+   * Adds a new search engine to the BrowserSearchService, based on its provided element. Prompts for an engine
+   * name, and appends a simple version-number in case of collision with an existing name.
+   *
+   * @return callback to handle success value. Currently used for ActionBarHandler.js and UI updates.
+   */
+  addEngine: function addEngine(aElement, resultCallback) {
     let form = aElement.form;
     let charset = aElement.ownerDocument.characterSet;
     let docURI = Services.io.newURI(aElement.ownerDocument.URL, charset, null);
     let formURL = Services.io.newURI(form.getAttribute("action"), charset, docURI).spec;
     let method = form.method.toUpperCase();
-    let formData = [];
-
-    for (let i = 0; i < form.elements.length; ++i) {
-      let el = form.elements[i];
-      if (!el.type)
-        continue;
-
-      // make this text field a generic search parameter
-      if (aElement == el) {
-        formData.push({ name: el.name, value: "{searchTerms}" });
-        continue;
-      }
-
-      let type = el.type.toLowerCase();
-      let escapedName = escape(el.name);
-      let escapedValue = escape(el.value);
-
-      // add other form elements as parameters
-      switch (el.type) {
-        case "checkbox":
-        case "radio":
-          if (!el.checked) break;
-        case "text":
-        case "hidden":
-        case "textarea":
-          formData.push({ name: escapedName, value: escapedValue });
-          break;
-        case "select-one":
-          for (let option of el.options) {
-            if (option.selected) {
-              formData.push({ name: escapedName, value: escapedValue });
-              break;
-            }
-          }
-      }
-    }
+    let formData = this._getSortedFormData(aElement);
 
     // prompt user for name of search engine
     let promptTitle = Strings.browser.GetStringFromName("contextmenu.addSearchEngine3");
     let title = { value: (aElement.ownerDocument.title || docURI.host) };
-    if (!Services.prompt.prompt(null, promptTitle, null, title, null, {}))
+    if (!Services.prompt.prompt(null, promptTitle, null, title, null, {})) {
+      if (resultCallback) {
+        resultCallback(false);
+      };
       return;
+    }
 
     // fetch the favicon for this page
     let dbFile = FileUtils.getFile("ProfD", ["browser.db"]);
     let mDBConn = Services.storage.openDatabase(dbFile);
     let stmts = [];
     stmts[0] = mDBConn.createStatement("SELECT favicon FROM history_with_favicons WHERE url = ?");
     stmts[0].bindByIndex(0, docURI.spec);
     let favicon = null;
+
     Services.search.init(function addEngine_cb(rv) {
       if (!Components.isSuccessCode(rv)) {
         Cu.reportError("Could not initialize search service, bailing out.");
+        if (resultCallback) {
+          resultCallback(false);
+        };
         return;
       }
+
       mDBConn.executeAsync(stmts, stmts.length, {
         handleResult: function (results) {
           let bytes = results.getNextRow().getResultByName("favicon");
           if (bytes && bytes.length) {
             favicon = "data:image/x-icon;base64," + btoa(String.fromCharCode.apply(null, bytes));
           }
         },
         handleCompletion: function (reason) {
           // if there's already an engine with this name, add a number to
           // make the name unique (e.g., "Google" becomes "Google 2")
           let name = title.value;
           for (let i = 2; Services.search.getEngineByName(name); i++)
             name = title.value + " " + i;
 
           Services.search.addEngineWithDetails(name, favicon, null, null, method, formURL);
           Snackbars.show(Strings.browser.formatStringFromName("alertSearchEngineAddedToast", [name], 1), Snackbars.LENGTH_LONG);
+
           let engine = Services.search.getEngineByName(name);
           engine.wrappedJSObject._queryCharset = charset;
-          for (let i = 0; i < formData.length; ++i) {
-            let param = formData[i];
-            if (param.name && param.value)
-              engine.addParam(param.name, param.value, null);
-          }
+          formData.forEach(param => { engine.addParam(param.name, param.value, null); });
+
+          if (resultCallback) {
+            return resultCallback(true);
+          };
         }
       });
     });
   }
 };
 
 var ActivityObserver = {
   init: function ao_init() {
--- a/netwerk/base/nsIBrowserSearchService.idl
+++ b/netwerk/base/nsIBrowserSearchService.idl
@@ -264,16 +264,33 @@ interface nsIBrowserSearchService : nsIS
   readonly attribute bool isInitialized;
 
   /**
    * Resets the default engine to its original value.
    */
   void resetToOriginalDefaultEngine();
 
   /**
+   * Checks if an EngineURL of type URLTYPE_SEARCH_HTML exists for
+   * any engine, with a matching method, template URL, and query params.
+   *
+   * @param method
+   *        The HTTP request method used when submitting a search query.
+   *        Must be a case insensitive value of either "get" or "post".
+   *
+   * @param url
+   *        The URL to which search queries should be sent.
+   *        Must not be null.
+   *
+   * @param formData
+   *        The un-sorted form data used as query params.
+   */
+  boolean hasEngineWithURL(in AString method, in AString url, in jsval formData);
+
+  /**
    * Adds a new search engine from the file at the supplied URI, optionally
    * asking the user for confirmation first.  If a confirmation dialog is
    * shown, it will offer the option to begin using the newly added engine
    * right away.
    *
    * @param engineURL
    *        The URL to the search engine's description file.
    *
--- a/toolkit/components/search/nsSearchService.js
+++ b/toolkit/components/search/nsSearchService.js
@@ -4352,16 +4352,64 @@ SearchService.prototype = {
       };
 
       processDomain(urlParsingInfo.mainDomain, false);
       SearchStaticData.getAlternateDomains(urlParsingInfo.mainDomain)
                       .forEach(d => processDomain(d, true));
     }
   },
 
+  /**
+   * Checks to see if any engine has an EngineURL of type URLTYPE_SEARCH_HTML
+   * for this request-method, template URL, and query params.
+   */
+  hasEngineWithURL: function(method, template, formData) {
+    this._ensureInitialized();
+
+    // Quick helper method to ensure formData filtered/sorted for compares.
+    let getSortedFormData = data => {
+      return data.filter(a => a.name && a.value).sort((a, b) => {
+        return (a.name > b.name) ? 1 : (b.name > a.name) ? -1 :
+               (a.value > b.value) ? 1 : (b.value > a.value) ? -1 : 0;
+      });
+    };
+
+    // Sanitize method, ensure formData is pre-sorted.
+    let methodUpper = method.toUpperCase();
+    let sortedFormData = getSortedFormData(formData);
+    let sortedFormLength = sortedFormData.length;
+
+    return this._getSortedEngines(false).some(engine => {
+      return engine._urls.some(url => {
+        // Not an engineURL match if type, method, url, #params don't match.
+        if (url.type != URLTYPE_SEARCH_HTML ||
+            url.method != methodUpper ||
+            url.template != template ||
+            url.params.length != sortedFormLength) {
+          return false;
+        };
+
+        // Ensure engineURL formData is pre-sorted. Then, we're
+        // not an engineURL match if any queryParam doesn't compare.
+        let sortedParams = getSortedFormData(url.params);
+        for (let i = 0; i < sortedFormLength; i++) {
+          let formData = sortedFormData[i];
+          let param = sortedParams[i];
+          if (param.name != formData.name ||
+              param.value != formData.value ||
+              param.purpose != formData.purpose) {
+            return false;
+          };
+        };
+        // Else we're a match.
+        return true;
+      });
+    });
+  },
+
   parseSubmissionURL: function SRCH_SVC_parseSubmissionURL(aURL) {
     this._ensureInitialized();
     LOG("parseSubmissionURL: Parsing \"" + aURL + "\".");
 
     if (!this._parseSubmissionMap) {
       this._buildParseSubmissionMap();
     }
 
new file mode 100644
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_hasEngineWithURL.js
@@ -0,0 +1,135 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the hasEngineWithURL() method of the nsIBrowserSearchService.
+ */
+function run_test() {
+  do_print("Setting up test");
+
+  updateAppInfo();
+  useHttpServer();
+
+  do_print("Test starting");
+  run_next_test();
+}
+
+
+// Return a discreet, cloned copy of an (engine) object.
+function getEngineClone(engine) {
+  return JSON.parse(JSON.stringify(engine));
+}
+
+// Check whether and engine does or doesn't exist.
+function checkEngineState(exists, engine) {
+  do_check_eq(exists, Services.search.hasEngineWithURL(engine.method,
+                                                       engine.formURL,
+                                                       engine.queryParams));
+}
+
+// Add a search engine for testing.
+function addEngineWithParams(engine) {
+  Services.search.addEngineWithDetails(engine.name, null, null, null,
+                                       engine.method, engine.formURL);
+
+  let addedEngine = Services.search.getEngineByName(engine.name);
+  for (let param of engine.queryParams) {
+    addedEngine.addParam(param.name, param.value, null);
+  }
+}
+
+// Main test.
+add_task(function* test_hasEngineWithURL() {
+  // Avoid deprecated synchronous initialization.
+  yield asyncInit();
+
+  // Setup various Engine definitions for method tests.
+  let UNSORTED_ENGINE = {
+    name: "mySearch Engine",
+    method: "GET",
+    formURL: "https://totallyNotRealSearchEngine.com/",
+    queryParams: [
+      { name: "DDs", value: "38s" },
+      { name: "DCs", value: "39s" },
+      { name: "DDs", value: "39s" },
+      { name: "DDs", value: "38s" },
+      { name: "DDs", value: "37s" },
+      { name: "DDs", value: "38s" },
+      { name: "DEs", value: "38s" },
+      { name: "DCs", value: "38s" },
+      { name: "DEs", value: "37s" },
+    ],
+  };
+
+  // Same as UNSORTED_ENGINE, but sorted.
+  let SORTED_ENGINE = {
+    name: "mySearch Engine",
+    method: "GET",
+    formURL: "https://totallyNotRealSearchEngine.com/",
+    queryParams: [
+      { name: "DCs", value: "38s" },
+      { name: "DCs", value: "39s" },
+      { name: "DDs", value: "37s" },
+      { name: "DDs", value: "38s" },
+      { name: "DDs", value: "38s" },
+      { name: "DDs", value: "38s" },
+      { name: "DDs", value: "39s" },
+      { name: "DEs", value: "37s" },
+      { name: "DEs", value: "38s" },
+    ],
+  };
+
+  // Unique variations of the SORTED_ENGINE.
+  let SORTED_ENGINE_METHOD_CHANGE = getEngineClone(SORTED_ENGINE);
+  SORTED_ENGINE_METHOD_CHANGE.method = "PoST";
+
+  let SORTED_ENGINE_FORMURL_CHANGE = getEngineClone(SORTED_ENGINE);
+  SORTED_ENGINE_FORMURL_CHANGE.formURL = "http://www.ahighrpowr.com/"
+
+  let SORTED_ENGINE_QUERYPARM_CHANGE = getEngineClone(SORTED_ENGINE);
+  SORTED_ENGINE_QUERYPARM_CHANGE.queryParams = [];
+
+  let SORTED_ENGINE_NAME_CHANGE = getEngineClone(SORTED_ENGINE);
+  SORTED_ENGINE_NAME_CHANGE.name += " 2";
+
+
+  // First ensure neither the unsorted engine, nor the same engine
+  // with a pre-sorted list of query parms matches.
+  checkEngineState(false, UNSORTED_ENGINE);
+  do_print("The unsorted version of the test engine does not exist.");
+  checkEngineState(false, SORTED_ENGINE);
+  do_print("The sorted version of the test engine does not exist.");
+
+  // Ensure variations of the engine definition do not match.
+  checkEngineState(false, SORTED_ENGINE_METHOD_CHANGE);
+  checkEngineState(false, SORTED_ENGINE_FORMURL_CHANGE);
+  checkEngineState(false, SORTED_ENGINE_QUERYPARM_CHANGE);
+  do_print("There are no modified versions of the sorted test engine.");
+
+  // Note that this method doesn't check name variations.
+  checkEngineState(false, SORTED_ENGINE_NAME_CHANGE);
+  do_print("There is no NAME modified version of the sorted test engine.");
+
+
+  // Add the unsorted engine and it's queryParams.
+  addEngineWithParams(UNSORTED_ENGINE);
+  do_print("The unsorted engine has been added.");
+
+
+  // Then, ensure we find a match for the unsorted engine, and for the
+  // same engine with a pre-sorted list of query parms.
+  checkEngineState(true, UNSORTED_ENGINE);
+  do_print("The unsorted version of the test engine now exists.");
+  checkEngineState(true, SORTED_ENGINE);
+  do_print("The sorted version of the same test engine also now exists.");
+
+  // Ensure variations of the engine definition still do not match.
+  checkEngineState(false, SORTED_ENGINE_METHOD_CHANGE);
+  checkEngineState(false, SORTED_ENGINE_FORMURL_CHANGE);
+  checkEngineState(false, SORTED_ENGINE_QUERYPARM_CHANGE);
+  do_print("There are still no modified versions of the sorted test engine.");
+
+  // Note that this method still doesn't check name variations.
+  checkEngineState(true, SORTED_ENGINE_NAME_CHANGE);
+  do_print("There IS now a NAME modified version of the sorted test engine.");
+});
--- a/toolkit/components/search/tests/xpcshell/xpcshell.ini
+++ b/toolkit/components/search/tests/xpcshell/xpcshell.ini
@@ -31,16 +31,17 @@ support-files =
   data/searchSuggestions.sjs
   data/searchTest.jar
 
 [test_nocache.js]
 [test_645970.js]
 [test_bug930456.js]
 [test_bug930456_child.js]
 [test_engine_set_alias.js]
+[test_hasEngineWithURL.js]
 [test_identifiers.js]
 [test_invalid_engine_from_dir.js]
 [test_init_async_multiple.js]
 [test_init_async_multiple_then_sync.js]
 [test_json_cache.js]
 [test_location.js]
 [test_location_error.js]
 [test_location_malformed_json.js]