Bug 1289913 - Show autocomplete UI on password fields.; r?MattN draft
authorSean Lee <selee@mozilla.com>
Thu, 03 Nov 2016 18:07:39 +0800
changeset 438127 db836d392eb2c601724d4526e5f60b8a07ee28a4
parent 437948 fc104971a4db41e38808e6412bc32e1900172f14
child 536836 f7b2699f3541bdc6460e47eab635f331d5a05d64
push id35631
push userbmo:selee@mozilla.com
push dateSun, 13 Nov 2016 19:37:47 +0000
reviewersMattN
bugs1289913
milestone52.0a1
Bug 1289913 - Show autocomplete UI on password fields.; r?MattN MozReview-Commit-ID: LGKM6igKbQB
modules/libpref/init/all.js
toolkit/components/passwordmgr/LoginHelper.jsm
toolkit/components/passwordmgr/LoginManagerContent.jsm
toolkit/components/passwordmgr/LoginManagerParent.jsm
toolkit/components/passwordmgr/nsLoginManager.js
toolkit/components/passwordmgr/test/mochitest/mochitest.ini
toolkit/components/passwordmgr/test/mochitest/test_basic_form_autocomplete.html
toolkit/components/passwordmgr/test/mochitest/test_insecure_form_field_autocomplete.html
toolkit/components/passwordmgr/test/mochitest/test_password_field_autocomplete.html
toolkit/components/passwordmgr/test/unit/test_user_autocomplete_result.js
toolkit/components/passwordmgr/test/unit/xpcshell.ini
toolkit/components/satchel/nsFormFillController.cpp
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -2166,16 +2166,19 @@ pref("security.sri.enable", true);
 pref("security.block_script_with_wrong_mime", true);
 
 // Block images of wrong MIME for XCTO: nosniff.
 pref("security.xcto_nosniff_block_images", false);
 
 // OCSP must-staple
 pref("security.ssl.enable_ocsp_must_staple", true);
 
+// Insecure Form Field Warning
+pref("security.insecure_field_warning.contextual.enabled", false);
+
 // Disable pinning checks by default.
 pref("security.cert_pinning.enforcement_level", 0);
 // Do not process hpkp headers rooted by not built in roots by default.
 // This is to prevent accidental pinning from MITM devices and is used
 // for tests.
 pref("security.cert_pinning.process_headers_from_non_builtin_roots", false);
 
 // If set to true, allow view-source URIs to be opened from URIs that share
--- a/toolkit/components/passwordmgr/LoginHelper.jsm
+++ b/toolkit/components/passwordmgr/LoginHelper.jsm
@@ -31,16 +31,17 @@ Cu.import("resource://gre/modules/XPCOMU
 this.LoginHelper = {
   /**
    * Warning: these only update if a logger was created.
    */
   debug: Services.prefs.getBoolPref("signon.debug"),
   formlessCaptureEnabled: Services.prefs.getBoolPref("signon.formlessCapture.enabled"),
   schemeUpgrades: Services.prefs.getBoolPref("signon.schemeUpgrades"),
   insecureAutofill: Services.prefs.getBoolPref("signon.autofillForms.http"),
+  showInsecureFieldWarning: Services.prefs.getBoolPref("security.insecure_field_warning.contextual.enabled"),
 
   createLogger(aLogPrefix) {
     let getMaxLogLevel = () => {
       return this.debug ? "debug" : "warn";
     };
 
     // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref.
     let ConsoleAPI = Cu.import("resource://gre/modules/Console.jsm", {}).ConsoleAPI;
@@ -54,16 +55,20 @@ this.LoginHelper = {
     Services.prefs.addObserver("signon.", () => {
       this.debug = Services.prefs.getBoolPref("signon.debug");
       this.formlessCaptureEnabled = Services.prefs.getBoolPref("signon.formlessCapture.enabled");
       this.schemeUpgrades = Services.prefs.getBoolPref("signon.schemeUpgrades");
       this.insecureAutofill = Services.prefs.getBoolPref("signon.autofillForms.http");
       logger.maxLogLevel = getMaxLogLevel();
     }, false);
 
+    Services.prefs.addObserver("security.insecure_field_warning.", () => {
+      this.showInsecureFieldWarning = Services.prefs.getBoolPref("security.insecure_field_warning.contextual.enabled");
+    }, false);
+
     return logger;
   },
 
   /**
    * Due to the way the signons2.txt file is formatted, we need to make
    * sure certain field values or characters do not cause the file to
    * be parsed incorrectly.  Reject hostnames that we can't store correctly.
    *
--- a/toolkit/components/passwordmgr/LoginManagerContent.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerContent.jsm
@@ -5,17 +5,16 @@
 "use strict";
 
 this.EXPORTED_SYMBOLS = [ "LoginManagerContent",
                           "LoginFormFactory",
                           "UserAutoCompleteResult" ];
 
 const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
 const PASSWORD_INPUT_ADDED_COALESCING_THRESHOLD_MS = 1;
-const PREF_INSECURE_FIELD_WARNING_ENABLED = "security.insecure_field_warning.contextual.enabled";
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm");
 Cu.import("resource://gre/modules/InsecurePasswordUtils.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 
@@ -308,16 +307,17 @@ var LoginManagerContent = {
 
     let requestData = {};
     let messageData = { formOrigin: formOrigin,
                         actionOrigin: actionOrigin,
                         searchString: aSearchString,
                         previousResult: previousResult,
                         rect: aRect,
                         isSecure: InsecurePasswordUtils.isFormSecure(form),
+                        isPasswordField: aElement.type == "password",
                         remote: remote };
 
     return this._sendRequest(messageManager, requestData,
                              "RemoteLogins:autoCompleteLogins",
                              messageData);
   },
 
   setupProgressListener(window) {
@@ -969,16 +969,18 @@ var LoginManagerContent = {
 
       // Need a valid password field to do anything.
       if (passwordField == null) {
         log("not filling form, no password field found");
         recordAutofillResult(AUTOFILL_RESULT.NO_PASSWORD_FIELD);
         return;
       }
 
+      this._formFillService.markAsLoginManagerField(passwordField);
+
       // If the password field is disabled or read-only, there's nothing to do.
       if (passwordField.disabled || passwordField.readOnly) {
         log("not filling form, password field disabled or read-only");
         recordAutofillResult(AUTOFILL_RESULT.PASSWORD_DISABLED_READONLY);
         return;
       }
 
       // Prevent autofilling insecure forms.
@@ -1022,18 +1024,19 @@ var LoginManagerContent = {
         log("form not filled, none of the logins fit in the field");
         recordAutofillResult(AUTOFILL_RESULT.NO_LOGINS_FIT);
         return;
       }
 
       // Attach autocomplete stuff to the username field, if we have
       // one. This is normally used to select from multiple accounts,
       // but even with one account we should refill if the user edits.
-      if (usernameField)
+      if (usernameField) {
         this._formFillService.markAsLoginManagerField(usernameField);
+      }
 
       // Don't clobber an existing password.
       if (passwordField.value && !clobberPassword) {
         log("form not filled, the password field was already filled");
         recordAutofillResult(AUTOFILL_RESULT.EXISTING_PASSWORD);
         return;
       }
 
@@ -1212,86 +1215,142 @@ var LoginUtils = {
     if (uriString == "")
       uriString = form.baseURI; // ala bug 297761
 
     return this._getPasswordOrigin(uriString, true);
   },
 };
 
 // nsIAutoCompleteResult implementation
-function UserAutoCompleteResult (aSearchString, matchingLogins, {isSecure, messageManager}) {
-  function loginSort(a, b) {
-    var userA = a.username.toLowerCase();
-    var userB = b.username.toLowerCase();
+function UserAutoCompleteResult (aSearchString, matchingLogins, {isSecure, messageManager, isPasswordField}) {
+  this.searchString = aSearchString;
 
-    if (userA < userB)
-      return -1;
-
-    if (userA > userB)
-      return  1;
-
-    return 0;
-  }
+  this._stringBundle = Services.strings.createBundle("chrome://passwordmgr/locale/passwordmgr.properties");
+  this._dateAndTimeFormatter = new Intl.DateTimeFormat(undefined,
+                              { day: "numeric", month: "short", year: "numeric" });
 
-  let prefShowInsecureFieldWarning =
-    Preferences.get(PREF_INSECURE_FIELD_WARNING_ENABLED, false);
+  this._messageManager = messageManager;
+  this._matchingLogins = matchingLogins;
+  this._isPasswordField = isPasswordField;
+  this._isSecure = isSecure;
 
-  this._showInsecureFieldWarning = (!isSecure && prefShowInsecureFieldWarning) ? 1 : 0;
-  this.searchString = aSearchString;
-  this.logins = matchingLogins.sort(loginSort);
-  this.matchCount = matchingLogins.length + this._showInsecureFieldWarning;
-  this._messageManager = messageManager;
-  this._stringBundle = Services.strings.createBundle("chrome://passwordmgr/locale/passwordmgr.properties");
+  Services.prefs.addObserver("security.insecure_field_warning.contextual.enabled",
+                             this.updateWithPrefChange.bind(this), false);
 
-  if (this.matchCount > 0) {
-    this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
-    this.defaultIndex = 0;
-  }
+  Services.prefs.addObserver("signon.autofillForms.http",
+                             this.updateWithPrefChange.bind(this), false);
+
+  this.updateWithPrefChange();
 }
 
 UserAutoCompleteResult.prototype = {
   QueryInterface : XPCOMUtils.generateQI([Ci.nsIAutoCompleteResult,
                                           Ci.nsISupportsWeakReference]),
 
   // private
   logins : null,
 
   // Allow autoCompleteSearch to get at the JS object so it can
   // modify some readonly properties for internal use.
   get wrappedJSObject() {
     return this;
   },
 
+  updateWithPrefChange() {
+    function loginSort(a, b) {
+      var userA = a.username.toLowerCase();
+      var userB = b.username.toLowerCase();
+
+      if (userA < userB)
+        return -1;
+
+      if (userA > userB)
+        return  1;
+
+      return 0;
+    }
+
+    function findDuplicates(loginList) {
+      let seen = new Set();
+      let duplicates = new Set();
+      for (let login of loginList) {
+        if (seen.has(login.username)) {
+          duplicates.add(login.username);
+        }
+        seen.add(login.username);
+      }
+      return duplicates;
+    }
+
+    let currentMatchingLogins = (!LoginHelper.insecureAutofill && !this._isSecure) ?
+                                [] : this._matchingLogins;
+
+    this._showInsecureFieldWarning = (!this._isSecure && LoginHelper.showInsecureFieldWarning) ? 1 : 0;
+    this.logins = currentMatchingLogins.sort(loginSort);
+    this.matchCount = currentMatchingLogins.length + this._showInsecureFieldWarning;
+    this._duplicateUsernames = findDuplicates(currentMatchingLogins);
+
+    if (this.matchCount > 0) {
+      this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
+      this.defaultIndex = 0;
+    }
+  },
+
   // Interfaces from idl...
   searchString : null,
   searchResult : Ci.nsIAutoCompleteResult.RESULT_NOMATCH,
   defaultIndex : -1,
   errorDescription : "",
   matchCount : 0,
 
   getValueAt(index) {
-    if (index < 0 || index >= this.matchCount)
+    if (index < 0 || index >= this.matchCount) {
       throw new Error("Index out of range.");
+    }
 
     if (this._showInsecureFieldWarning && index === 0) {
       return "";
     }
 
-    return this.logins[index - this._showInsecureFieldWarning].username;
+    let selectedLogin = this.logins[index - this._showInsecureFieldWarning];
+
+    return this._isPasswordField ? selectedLogin.password : selectedLogin.username;
   },
 
   getLabelAt(index) {
-    if (index < 0 || index >= this.matchCount)
+    if (index < 0 || index >= this.matchCount) {
       throw new Error("Index out of range.");
+    }
 
     if (this._showInsecureFieldWarning && index === 0) {
       return this._stringBundle.GetStringFromName("insecureFieldWarningDescription");
     }
 
-    return this.logins[index - this._showInsecureFieldWarning].username;
+    let that = this;
+
+    function getLocalizedString(key, formatArgs) {
+      if (formatArgs) {
+        return that._stringBundle.formatStringFromName(key, formatArgs, formatArgs.length);
+      }
+      return that._stringBundle.GetStringFromName(key);
+    }
+
+    let login = this.logins[index - this._showInsecureFieldWarning];
+    let username = login.username;
+    // If login is empty or duplicated we want to append a modification date to it.
+    if (!username || this._duplicateUsernames.has(username)) {
+      if (!username) {
+        username = getLocalizedString("noUsername");
+      }
+      let meta = login.QueryInterface(Ci.nsILoginMetaInfo);
+      let time = this._dateAndTimeFormatter.format(new Date(meta.timePasswordChanged));
+      username = getLocalizedString("loginHostAge", [username, time]);
+    }
+
+    return username;
   },
 
   getCommentAt(index) {
     return "";
   },
 
   getStyleAt(index) {
     if (index == 0 && this._showInsecureFieldWarning) {
@@ -1304,18 +1363,19 @@ UserAutoCompleteResult.prototype = {
     return "";
   },
 
   getFinalCompleteValueAt(index) {
     return this.getValueAt(index);
   },
 
   removeValueAt(index, removeFromDB) {
-    if (index < 0 || index >= this.matchCount)
-        throw new Error("Index out of range.");
+    if (index < 0 || index >= this.matchCount) {
+      throw new Error("Index out of range.");
+    }
 
     if (this._showInsecureFieldWarning && index === 0) {
       // Ignore the warning message item.
       return;
     }
     if (this._showInsecureFieldWarning) {
       index--;
     }
--- a/toolkit/components/passwordmgr/LoginManagerParent.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerParent.jsm
@@ -222,17 +222,18 @@ var LoginManagerParent = {
       requestId: requestId,
       logins: jsLogins,
       recipes,
     });
   }),
 
   doAutocompleteSearch: function({ formOrigin, actionOrigin,
                                    searchString, previousResult,
-                                   rect, requestId, isSecure, remote }, target) {
+                                   rect, requestId, isSecure, isPasswordField,
+                                   remote }, target) {
     // Note: previousResult is a regular object, not an
     // nsIAutoCompleteResult.
 
     let searchStringLower = searchString.toLowerCase();
     let logins;
     if (previousResult &&
         searchStringLower.startsWith(previousResult.searchString.toLowerCase())) {
       log("Using previous autocomplete result");
@@ -255,17 +256,21 @@ var LoginManagerParent = {
       ];
       logins = LoginHelper.dedupeLogins(logins, ["username"], resolveBy, formOrigin);
     }
 
     let matchingLogins = logins.filter(function(fullMatch) {
       let match = fullMatch.username;
 
       // Remove results that are too short, or have different prefix.
-      // Also don't offer empty usernames as possible results.
+      // Also don't offer empty usernames as possible results except
+      // for password field.
+      if (isPasswordField) {
+        return true;
+      }
       return match && match.toLowerCase().startsWith(searchStringLower);
     });
 
     // XXX In the E10S case, we're responsible for showing our own
     // autocomplete popup here because the autocomplete protocol hasn't
     // been e10s-ized yet. In the non-e10s case, our caller is responsible
     // for showing the autocomplete popup (via the regular
     // nsAutoCompleteController).
--- a/toolkit/components/passwordmgr/nsLoginManager.js
+++ b/toolkit/components/passwordmgr/nsLoginManager.js
@@ -479,16 +479,21 @@ LoginManager.prototype = {
    */
   autoCompleteSearchAsync(aSearchString, aPreviousResult,
                           aElement, aCallback) {
     // aPreviousResult is an nsIAutoCompleteResult, aElement is
     // nsIDOMHTMLInputElement
 
     let form = LoginFormFactory.createFromField(aElement);
     let isSecure = InsecurePasswordUtils.isFormSecure(form);
+    let isPasswordField = aElement.type == "password";
+    if (isPasswordField) {
+      // The login items won't be filtered for password field.
+      aSearchString = "";
+    }
 
     if (!this._remember) {
       setTimeout(function() {
         aCallback.onSearchCompletion(new UserAutoCompleteResult(aSearchString, [], {isSecure}));
       }, 0);
       return;
     }
 
@@ -513,16 +518,17 @@ LoginManager.prototype = {
                                  return;
                                }
 
                                this._autoCompleteLookupPromise = null;
                                let results =
                                  new UserAutoCompleteResult(aSearchString, logins, {
                                    messageManager,
                                    isSecure,
+                                   isPasswordField,
                                  });
                                aCallback.onSearchCompletion(results);
                              })
                             .then(null, Cu.reportError);
   },
 
   stopSearch() {
     this._autoCompleteLookupPromise = null;
--- a/toolkit/components/passwordmgr/test/mochitest/mochitest.ini
+++ b/toolkit/components/passwordmgr/test/mochitest/mochitest.ini
@@ -22,16 +22,18 @@ skip-if = toolkit == 'android' # Bug 125
 [test_basic_form_1pw_2.html]
 [test_basic_form_2pw_1.html]
 [test_basic_form_2pw_2.html]
 [test_basic_form_3pw_1.html]
 [test_basic_form_autocomplete.html]
 skip-if = toolkit == 'android' # android:autocomplete.
 [test_insecure_form_field_autocomplete.html]
 skip-if = toolkit == 'android' # android:autocomplete.
+[test_password_field_autocomplete.html]
+skip-if = toolkit == 'android' # android:autocomplete.
 [test_basic_form_html5.html]
 [test_basic_form_pwevent.html]
 [test_basic_form_pwonly.html]
 [test_bug_627616.html]
 skip-if = toolkit == 'android' # Tests desktop prompts
 [test_bug_776171.html]
 [test_case_differences.html]
 skip-if = toolkit == 'android' # autocomplete
--- a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_autocomplete.html
+++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_autocomplete.html
@@ -204,29 +204,53 @@ function sendFakeAutocompleteEvent(eleme
   element.dispatchEvent(acEvent);
 }
 
 function spinEventLoop() {
   return Promise.resolve();
 }
 
 add_task(function* setup() {
-  yield SpecialPowers.pushPrefEnv({"set": [["security.insecure_field_warning.contextual.enabled", false]]});
+  yield SpecialPowers.pushPrefEnv({"set": [["security.insecure_field_warning.contextual.enabled", false],
+                                           ["signon.autofillForms.http", true]]});
   listenForUnexpectedPopupShown();
 });
 
 add_task(function* test_form1_initial_empty() {
   yield SimpleTest.promiseFocus(window);
 
   // Make sure initial form is empty.
   checkACForm("", "");
   let popupState = yield getPopupState();
   is(popupState.open, false, "Check popup is initially closed");
 });
 
+add_task(function* test_form1_menuitems() {
+  yield SimpleTest.promiseFocus(window);
+  // Trigger autocomplete popup
+  restoreForm();
+  let shownPromise = promiseACShown();
+  doKey("down"); // open
+  let results = yield shownPromise;
+
+  let popupState = yield getPopupState();
+  is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+
+  let expectedMenuItems = ["tempuser1",
+                           "testuser2",
+                           "testuser3",
+                           "zzzuser4"];
+  checkArrayValues(results, expectedMenuItems, "Check all menuitems are displayed correctly.");
+
+  checkACForm("", ""); // value shouldn't update just by selecting
+  doKey("return"); // not "enter"!
+  yield spinEventLoop(); // let focus happen
+  checkACForm("", "");
+});
+
 add_task(function* test_form1_first_entry() {
   yield SimpleTest.promiseFocus(window);
   // Trigger autocomplete popup
   restoreForm();
   let shownPromise = promiseACShown();
   doKey("down"); // open
   yield shownPromise;
 
--- a/toolkit/components/passwordmgr/test/mochitest/test_insecure_form_field_autocomplete.html
+++ b/toolkit/components/passwordmgr/test/mochitest/test_insecure_form_field_autocomplete.html
@@ -204,17 +204,18 @@ function sendFakeAutocompleteEvent(eleme
   element.dispatchEvent(acEvent);
 }
 
 function spinEventLoop() {
   return Promise.resolve();
 }
 
 add_task(function* setup() {
-  yield SpecialPowers.pushPrefEnv({"set": [["security.insecure_field_warning.contextual.enabled", true]]});
+  yield SpecialPowers.pushPrefEnv({"set": [["security.insecure_field_warning.contextual.enabled", true],
+                                           ["signon.autofillForms.http", true]]});
   listenForUnexpectedPopupShown();
 });
 
 add_task(function* test_form1_initial_empty() {
   yield SimpleTest.promiseFocus(window);
 
   // Make sure initial form is empty.
   checkACForm("", "");
@@ -223,21 +224,28 @@ add_task(function* test_form1_initial_em
 });
 
 add_task(function* test_form1_warning_entry() {
   yield SimpleTest.promiseFocus(window);
   // Trigger autocomplete popup
   restoreForm();
   let shownPromise = promiseACShown();
   doKey("down"); // open
-  yield shownPromise;
+  let results = yield shownPromise;
 
   let popupState = yield getPopupState();
   is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
 
+  let expectedMenuItems = ["This connection is not secure. Logins entered here could be compromised.",
+                           "tempuser1",
+                           "testuser2",
+                           "testuser3",
+                           "zzzuser4"];
+  checkArrayValues(results, expectedMenuItems, "Check all menuitems are displayed correctly.");
+
   doKey("down"); // select insecure warning
   checkACForm("", ""); // value shouldn't update just by selecting
   doKey("return"); // not "enter"!
   yield spinEventLoop(); // let focus happen
   checkACForm("", "");
 });
 
 add_task(function* test_form1_first_entry() {
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_password_field_autocomplete.html
@@ -0,0 +1,275 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Test basic login autocomplete</title>
+  <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="text/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
+  <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
+  <script type="text/javascript" src="satchel_common.js"></script>
+  <script type="text/javascript" src="pwmgr_common.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: multiple login autocomplete
+
+<script>
+var chromeScript = runChecksAfterCommonInit();
+
+var setupScript = runInParent(function setup() {
+  const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+  Cu.import("resource://gre/modules/Services.jsm");
+
+  // Create some logins just for this form, since we'll be deleting them.
+  var nsLoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
+                                           Ci.nsILoginInfo, "init");
+  assert.ok(nsLoginInfo != null, "nsLoginInfo constructor");
+
+  // login0 has no username, so should be filtered out from the autocomplete list.
+  var login0 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+                               "", "user0pass", "", "pword");
+
+  var login1 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+                               "tempuser1", "temppass1", "uname", "pword");
+
+  var login2 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+                               "testuser2", "testpass2", "uname", "pword");
+
+  var login3 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+                               "testuser3", "testpass3", "uname", "pword");
+
+  var login4 = new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+                               "zzzuser4", "zzzpass4", "uname", "pword");
+
+
+  // try/catch in case someone runs the tests manually, twice.
+  try {
+    Services.logins.addLogin(login0);
+    Services.logins.addLogin(login1);
+    Services.logins.addLogin(login2);
+    Services.logins.addLogin(login3);
+    Services.logins.addLogin(login4);
+  } catch (e) {
+    assert.ok(false, "addLogin threw: " + e);
+  }
+
+  addMessageListener("addLogin", loginVariableName => {
+    let login = eval(loginVariableName);
+    assert.ok(!!login, "Login to add is defined: " + loginVariableName);
+    Services.logins.addLogin(login);
+  });
+  addMessageListener("removeLogin", loginVariableName => {
+    let login = eval(loginVariableName);
+    assert.ok(!!login, "Login to delete is defined: " + loginVariableName);
+    Services.logins.removeLogin(login);
+  });
+});
+</script>
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+
+  <!-- form1 tests multiple matching logins -->
+  <form id="form1" action="http://autocomplete:8888/formtest.js" onsubmit="return false;">
+    <input  type="text"       name="uname">
+    <input  type="password"   name="pword">
+    <button type="submit">Submit</button>
+  </form>
+
+  <form id="form2" action="http://autocomplete:8888/formtest.js" onsubmit="return false;">
+    <input  type="text"       name="uname">
+    <input  type="password"   name="pword" readonly="true">
+    <button type="submit">Submit</button>
+  </form>
+
+  <form id="form3" action="http://autocomplete:8888/formtest.js" onsubmit="return false;">
+    <input  type="text"       name="uname">
+    <input  type="password"   name="pword" disabled="true">
+    <button type="submit">Submit</button>
+  </form>
+
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Login Manager: multiple login autocomplete. **/
+
+var uname = $_(1, "uname");
+var pword = $_(1, "pword");
+const shiftModifier = SpecialPowers.Ci.nsIDOMEvent.SHIFT_MASK;
+
+// Restore the form to the default state.
+function restoreForm(index) {
+  // Using innerHTML is for creating the autocomplete popup again, so the
+  // preference value will be applied to the constructor of
+  // UserAutoCompleteResult.
+  let form = document.getElementById("form" + index);
+  let temp = form.innerHTML;
+  form.innerHTML = "";
+  form.innerHTML = temp;
+  uname = $_(index, "uname");
+  pword = $_(index, "pword");
+
+  uname.value = "";
+  pword.value = "";
+  pword.focus();
+}
+
+function generateDateString(date) {
+  let dateAndTimeFormatter = new Intl.DateTimeFormat(undefined,
+                             { day: "numeric", month: "short", year: "numeric" });
+  return dateAndTimeFormatter.format(date);
+}
+
+const DATE_NOW_STRING = generateDateString(new Date());
+
+// Check for expected username/password in form.
+function checkACFormPasswordField(expectedPassword) {
+  var formID = uname.parentNode.id;
+  is(pword.value, expectedPassword, "Checking " + formID + " password is: " + expectedPassword);
+}
+
+function spinEventLoop() {
+  return Promise.resolve();
+}
+
+add_task(function* setup() {
+  listenForUnexpectedPopupShown();
+});
+
+add_task(function* test_form1_initial_empty() {
+  yield SimpleTest.promiseFocus(window);
+
+  // Make sure initial form is empty.
+  checkACFormPasswordField("");
+  let popupState = yield getPopupState();
+  is(popupState.open, false, "Check popup is initially closed");
+});
+
+add_task(function* test_form1_enabledInsecureFieldWarning_enabledInsecureAutoFillForm() {
+  yield SpecialPowers.pushPrefEnv({"set": [
+                                            ["security.insecure_field_warning.contextual.enabled", true],
+                                            ["signon.autofillForms.http", true]
+                                          ]});
+  restoreForm(1);
+  yield SimpleTest.promiseFocus(window);
+  // Trigger autocomplete popup
+  is(SpecialPowers.getBoolPref("security.insecure_field_warning.contextual.enabled"), true, "verify pref security.insecure_field_warning.contextual.enabled");
+  is(SpecialPowers.getBoolPref("signon.autofillForms.http"), true, "verify pref signon.autofillForms.http");
+  let shownPromise = promiseACShown();
+  doKey("down"); // open
+  let results = yield shownPromise;
+
+  let popupState = yield getPopupState();
+  is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+
+  let expectedMenuItems = ["This connection is not secure. Logins entered here could be compromised.",
+                           "No username (" + DATE_NOW_STRING + ")",
+                           "tempuser1",
+                           "testuser2",
+                           "testuser3",
+                           "zzzuser4"];
+  checkArrayValues(results, expectedMenuItems, "Check all menuitems are displayed correctly.");
+
+  doKey("down"); // select insecure warning
+  checkACFormPasswordField(""); // value shouldn't update just by selecting
+  doKey("return"); // not "enter"!
+  yield spinEventLoop(); // let focus happen
+  checkACFormPasswordField("");
+});
+
+add_task(function* test_form1_disabledInsecureFieldWarning_enabledInsecureAutoFillForm() {
+  yield SpecialPowers.pushPrefEnv({"set": [
+                                            ["security.insecure_field_warning.contextual.enabled", false],
+                                            ["signon.autofillForms.http", true]
+                                          ]});
+  restoreForm(1);
+  yield SimpleTest.promiseFocus(window);
+  // Trigger autocomplete popup
+  is(SpecialPowers.getBoolPref("security.insecure_field_warning.contextual.enabled"), false, "verify pref security.insecure_field_warning.contextual.enabled");
+  is(SpecialPowers.getBoolPref("signon.autofillForms.http"), true, "verify pref signon.autofillForms.http");
+  let shownPromise = promiseACShown();
+  doKey("down"); // open
+  let results = yield shownPromise;
+
+  let popupState = yield getPopupState();
+  is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+
+  let expectedMenuItems = ["No username (" + DATE_NOW_STRING + ")",
+                           "tempuser1",
+                           "testuser2",
+                           "testuser3",
+                           "zzzuser4"];
+  checkArrayValues(results, expectedMenuItems, "Check all menuitems are displayed correctly.");
+
+  doKey("down"); // select first item
+  checkACFormPasswordField(""); // value shouldn't update just by selecting
+  doKey("return"); // not "enter"!
+  yield spinEventLoop(); // let focus happen
+  checkACFormPasswordField("user0pass");
+});
+
+add_task(function* test_form1_enabledInsecureFieldWarning_disabledInsecureAutoFillForm() {
+  yield SpecialPowers.pushPrefEnv({"set": [
+                                            ["security.insecure_field_warning.contextual.enabled", true],
+                                            ["signon.autofillForms.http", false]
+                                          ]});
+  restoreForm(1);
+  yield SimpleTest.promiseFocus(window);
+  // Trigger autocomplete popup
+  is(SpecialPowers.getBoolPref("security.insecure_field_warning.contextual.enabled"), true, "verify pref security.insecure_field_warning.contextual.enabled");
+  is(SpecialPowers.getBoolPref("signon.autofillForms.http"), false, "verify pref signon.autofillForms.http");
+  let shownPromise = promiseACShown();
+  doKey("down"); // open
+  let results = yield shownPromise;
+
+  let popupState = yield getPopupState();
+  is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+
+  let expectedMenuItems = ["This connection is not secure. Logins entered here could be compromised."];
+  checkArrayValues(results, expectedMenuItems, "Check all menuitems are displayed correctly.");
+
+  doKey("down"); // select insecure warning
+  checkACFormPasswordField(""); // value shouldn't update just by selecting
+  doKey("return"); // not "enter"!
+  yield spinEventLoop(); // let focus happen
+  checkACFormPasswordField("");
+});
+
+add_task(function* test_form1_disabledInsecureFieldWarning_disabledInsecureAutoFillForm() {
+  yield SpecialPowers.pushPrefEnv({"set": [
+                                            ["security.insecure_field_warning.contextual.enabled", false],
+                                            ["signon.autofillForms.http", false]
+                                          ]});
+  restoreForm(1);
+  yield SimpleTest.promiseFocus(window);
+  // Trigger autocomplete popup
+  is(SpecialPowers.getBoolPref("security.insecure_field_warning.contextual.enabled"), false, "verify pref security.insecure_field_warning.contextual.enabled");
+  is(SpecialPowers.getBoolPref("signon.autofillForms.http"), false, "verify pref signon.autofillForms.http");
+  doKey("down"); // open
+  let popupState = yield getPopupState();
+  is(popupState.open, false, "Check popup is closed with no AutoFillForms.");
+});
+
+add_task(function* test_form2_password_readonly() {
+  // Trigger autocomplete popup
+  restoreForm(2);
+  doKey("down"); // open
+  let popupState = yield getPopupState();
+  is(popupState.open, false, "Check popup is closed for a readonly field.");
+});
+
+add_task(function* test_form3_password_disabled() {
+  // Trigger autocomplete popup
+  restoreForm(3);
+  doKey("down"); // open
+  let popupState = yield getPopupState();
+  is(popupState.open, false, "Check popup is closed for a disabled field.");
+});
+
+</script>
+</pre>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_user_autocomplete_result.js
@@ -0,0 +1,448 @@
+XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
+                                  "resource://gre/modules/LoginHelper.jsm");
+Cu.import("resource://gre/modules/LoginManagerContent.jsm");
+var nsLoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
+                                         Ci.nsILoginInfo, "init");
+
+const PREF_INSECURE_FIELD_WARNING_ENABLED = "security.insecure_field_warning.contextual.enabled";
+const PREF_INSECURE_AUTOFILLFORMS_ENABLED = "signon.autofillForms.http";
+
+let matchingLogins = [];
+matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+                                    "", "emptypass1", "uname", "pword"));
+
+matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+                                    "tempuser1", "temppass1", "uname", "pword"));
+
+matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+                                    "testuser2", "testpass2", "uname", "pword"));
+
+matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+                                    "testuser3", "testpass3", "uname", "pword"));
+
+matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+                                    "zzzuser4", "zzzpass4", "uname", "pword"));
+
+let meta = matchingLogins[0].QueryInterface(Ci.nsILoginMetaInfo);
+let dateAndTimeFormatter = new Intl.DateTimeFormat(undefined,
+                            { day: "numeric", month: "short", year: "numeric" });
+let time = dateAndTimeFormatter.format(new Date(meta.timePasswordChanged));
+const LABEL_NO_USERNAME = "No username (" + time + ")";
+
+let expectedResults = [
+  {
+    insecureFieldWarningEnabled: true,
+    insecureAutoFillFormsEnabled: true,
+    isSecure: true,
+    isPasswordField: false,
+    matchingLogins: matchingLogins,
+    items: [{
+      value: "",
+      label: LABEL_NO_USERNAME,
+      style: ""
+    }, {
+      value: "tempuser1",
+      label: "tempuser1",
+      style: ""
+    }, {
+      value: "testuser2",
+      label: "testuser2",
+      style: ""
+    }, {
+      value: "testuser3",
+      label: "testuser3",
+      style: ""
+    }, {
+      value: "zzzuser4",
+      label: "zzzuser4",
+      style: ""
+    }]
+  },
+  {
+    insecureFieldWarningEnabled: true,
+    insecureAutoFillFormsEnabled: true,
+    isSecure: false,
+    isPasswordField: false,
+    matchingLogins: matchingLogins,
+    items: [{
+      value: "",
+      label: "This connection is not secure. Logins entered here could be compromised.",
+      style: "insecureWarning"
+    }, {
+      value: "",
+      label: LABEL_NO_USERNAME,
+      style: ""
+    }, {
+      value: "tempuser1",
+      label: "tempuser1",
+      style: ""
+    }, {
+      value: "testuser2",
+      label: "testuser2",
+      style: ""
+    }, {
+      value: "testuser3",
+      label: "testuser3",
+      style: ""
+    }, {
+      value: "zzzuser4",
+      label: "zzzuser4",
+      style: ""
+    }]
+  },
+  {
+    insecureFieldWarningEnabled: true,
+    insecureAutoFillFormsEnabled: true,
+    isSecure: true,
+    isPasswordField: true,
+    matchingLogins: matchingLogins,
+    items: [{
+      value: "emptypass1",
+      label: LABEL_NO_USERNAME,
+      style: ""
+    }, {
+      value: "temppass1",
+      label: "tempuser1",
+      style: ""
+    }, {
+      value: "testpass2",
+      label: "testuser2",
+      style: ""
+    }, {
+      value: "testpass3",
+      label: "testuser3",
+      style: ""
+    }, {
+      value: "zzzpass4",
+      label: "zzzuser4",
+      style: ""
+    }]
+  },
+  {
+    insecureFieldWarningEnabled: true,
+    insecureAutoFillFormsEnabled: true,
+    isSecure: false,
+    isPasswordField: true,
+    matchingLogins: matchingLogins,
+    items: [{
+      value: "",
+      label: "This connection is not secure. Logins entered here could be compromised.",
+      style: "insecureWarning"
+    }, {
+      value: "emptypass1",
+      label: LABEL_NO_USERNAME,
+      style: ""
+    }, {
+      value: "temppass1",
+      label: "tempuser1",
+      style: ""
+    }, {
+      value: "testpass2",
+      label: "testuser2",
+      style: ""
+    }, {
+      value: "testpass3",
+      label: "testuser3",
+      style: ""
+    }, {
+      value: "zzzpass4",
+      label: "zzzuser4",
+      style: ""
+    }]
+  },
+  {
+    insecureFieldWarningEnabled: false,
+    insecureAutoFillFormsEnabled: true,
+    isSecure: true,
+    isPasswordField: false,
+    matchingLogins: matchingLogins,
+    items: [{
+      value: "",
+      label: LABEL_NO_USERNAME,
+      style: ""
+    }, {
+      value: "tempuser1",
+      label: "tempuser1",
+      style: ""
+    }, {
+      value: "testuser2",
+      label: "testuser2",
+      style: ""
+    }, {
+      value: "testuser3",
+      label: "testuser3",
+      style: ""
+    }, {
+      value: "zzzuser4",
+      label: "zzzuser4",
+      style: ""
+    }]
+  },
+  {
+    insecureFieldWarningEnabled: false,
+    insecureAutoFillFormsEnabled: true,
+    isSecure: false,
+    isPasswordField: false,
+    matchingLogins: matchingLogins,
+    items: [{
+      value: "",
+      label: LABEL_NO_USERNAME,
+      style: ""
+    }, {
+      value: "tempuser1",
+      label: "tempuser1",
+      style: ""
+    }, {
+      value: "testuser2",
+      label: "testuser2",
+      style: ""
+    }, {
+      value: "testuser3",
+      label: "testuser3",
+      style: ""
+    }, {
+      value: "zzzuser4",
+      label: "zzzuser4",
+      style: ""
+    }]
+  },
+  {
+    insecureFieldWarningEnabled: false,
+    insecureAutoFillFormsEnabled: true,
+    isSecure: true,
+    isPasswordField: true,
+    matchingLogins: matchingLogins,
+    items: [{
+      value: "emptypass1",
+      label: LABEL_NO_USERNAME,
+      style: ""
+    }, {
+      value: "temppass1",
+      label: "tempuser1",
+      style: ""
+    }, {
+      value: "testpass2",
+      label: "testuser2",
+      style: ""
+    }, {
+      value: "testpass3",
+      label: "testuser3",
+      style: ""
+    }, {
+      value: "zzzpass4",
+      label: "zzzuser4",
+      style: ""
+    }]
+  },
+  {
+    insecureFieldWarningEnabled: false,
+    insecureAutoFillFormsEnabled: true,
+    isSecure: false,
+    isPasswordField: true,
+    matchingLogins: matchingLogins,
+    items: [{
+      value: "emptypass1",
+      label: LABEL_NO_USERNAME,
+      style: ""
+    }, {
+      value: "temppass1",
+      label: "tempuser1",
+      style: ""
+    }, {
+      value: "testpass2",
+      label: "testuser2",
+      style: ""
+    }, {
+      value: "testpass3",
+      label: "testuser3",
+      style: ""
+    }, {
+      value: "zzzpass4",
+      label: "zzzuser4",
+      style: ""
+    }]
+  },
+  {
+    insecureFieldWarningEnabled: true,
+    insecureAutoFillFormsEnabled: false,
+    isSecure: true,
+    isPasswordField: false,
+    matchingLogins: matchingLogins,
+    items: [{
+      value: "",
+      label: LABEL_NO_USERNAME,
+      style: ""
+    }, {
+      value: "tempuser1",
+      label: "tempuser1",
+      style: ""
+    }, {
+      value: "testuser2",
+      label: "testuser2",
+      style: ""
+    }, {
+      value: "testuser3",
+      label: "testuser3",
+      style: ""
+    }, {
+      value: "zzzuser4",
+      label: "zzzuser4",
+      style: ""
+    }]
+  },
+  {
+    insecureFieldWarningEnabled: true,
+    insecureAutoFillFormsEnabled: false,
+    isSecure: false,
+    isPasswordField: false,
+    matchingLogins: matchingLogins,
+    items: [{
+      value: "",
+      label: "This connection is not secure. Logins entered here could be compromised.",
+      style: "insecureWarning"
+    }]
+  },
+  {
+    insecureFieldWarningEnabled: true,
+    insecureAutoFillFormsEnabled: false,
+    isSecure: true,
+    isPasswordField: true,
+    matchingLogins: matchingLogins,
+    items: [{
+      value: "emptypass1",
+      label: LABEL_NO_USERNAME,
+      style: ""
+    }, {
+      value: "temppass1",
+      label: "tempuser1",
+      style: ""
+    }, {
+      value: "testpass2",
+      label: "testuser2",
+      style: ""
+    }, {
+      value: "testpass3",
+      label: "testuser3",
+      style: ""
+    }, {
+      value: "zzzpass4",
+      label: "zzzuser4",
+      style: ""
+    }]
+  },
+  {
+    insecureFieldWarningEnabled: true,
+    insecureAutoFillFormsEnabled: false,
+    isSecure: false,
+    isPasswordField: true,
+    matchingLogins: matchingLogins,
+    items: [{
+      value: "",
+      label: "This connection is not secure. Logins entered here could be compromised.",
+      style: "insecureWarning"
+    }]
+  },
+  {
+    insecureFieldWarningEnabled: false,
+    insecureAutoFillFormsEnabled: false,
+    isSecure: true,
+    isPasswordField: false,
+    matchingLogins: matchingLogins,
+    items: [{
+      value: "",
+      label: LABEL_NO_USERNAME,
+      style: ""
+    }, {
+      value: "tempuser1",
+      label: "tempuser1",
+      style: ""
+    }, {
+      value: "testuser2",
+      label: "testuser2",
+      style: ""
+    }, {
+      value: "testuser3",
+      label: "testuser3",
+      style: ""
+    }, {
+      value: "zzzuser4",
+      label: "zzzuser4",
+      style: ""
+    }]
+  },
+  {
+    insecureFieldWarningEnabled: false,
+    insecureAutoFillFormsEnabled: false,
+    isSecure: false,
+    isPasswordField: false,
+    matchingLogins: matchingLogins,
+    items: []
+  },
+  {
+    insecureFieldWarningEnabled: false,
+    insecureAutoFillFormsEnabled: false,
+    isSecure: true,
+    isPasswordField: true,
+    matchingLogins: matchingLogins,
+    items: [{
+      value: "emptypass1",
+      label: LABEL_NO_USERNAME,
+      style: ""
+    }, {
+      value: "temppass1",
+      label: "tempuser1",
+      style: ""
+    }, {
+      value: "testpass2",
+      label: "testuser2",
+      style: ""
+    }, {
+      value: "testpass3",
+      label: "testuser3",
+      style: ""
+    }, {
+      value: "zzzpass4",
+      label: "zzzuser4",
+      style: ""
+    }]
+  },
+  {
+    insecureFieldWarningEnabled: false,
+    insecureAutoFillFormsEnabled: false,
+    isSecure: false,
+    isPasswordField: true,
+    matchingLogins: matchingLogins,
+    items: []
+  },
+];
+
+add_task(function* test_all_patterns() {
+  LoginHelper.createLogger("UserAutoCompleteResult");
+  expectedResults.forEach(pattern => {
+    Services.prefs.setBoolPref(PREF_INSECURE_FIELD_WARNING_ENABLED,
+                               pattern.insecureFieldWarningEnabled);
+    Services.prefs.setBoolPref(PREF_INSECURE_AUTOFILLFORMS_ENABLED,
+                               pattern.insecureAutoFillFormsEnabled);
+    let actual = new UserAutoCompleteResult("", pattern.matchingLogins,
+                                            {
+                                              isSecure: pattern.isSecure,
+                                              isPasswordField: pattern.isPasswordField
+                                            });
+    pattern.items.forEach((item, index) => {
+      equal(actual.getValueAt(index), item.value);
+      equal(actual.getLabelAt(index), item.label);
+      equal(actual.getStyleAt(index), item.style);
+    });
+
+    if (pattern.items.length != 0) {
+      Assert.throws(() => actual.getValueAt(pattern.items.length),
+        /Index out of range\./);
+
+      Assert.throws(() => actual.getLabelAt(pattern.items.length),
+        /Index out of range\./);
+
+      Assert.throws(() => actual.removeValueAt(pattern.items.length, true),
+        /Index out of range\./);
+    }
+  });
+});
--- a/toolkit/components/passwordmgr/test/unit/xpcshell.ini
+++ b/toolkit/components/passwordmgr/test/unit/xpcshell.ini
@@ -26,16 +26,18 @@ run-if = buildapp == "browser"
 [test_getPasswordFields.js]
 [test_getPasswordOrigin.js]
 [test_isOriginMatching.js]
 [test_legacy_empty_formSubmitURL.js]
 [test_legacy_validation.js]
 [test_logins_change.js]
 [test_logins_decrypt_failure.js]
 skip-if = os == "android" # Bug 1171687: Needs fixing on Android
+[test_user_autocomplete_result.js]
+skip-if = os == "android"
 [test_logins_metainfo.js]
 [test_logins_search.js]
 [test_notifications.js]
 [test_OSCrypto_win.js]
 skip-if = os != "win"
 [test_recipes_add.js]
 [test_recipes_content.js]
 [test_search_schemeUpgrades.js]
--- a/toolkit/components/satchel/nsFormFillController.cpp
+++ b/toolkit/components/satchel/nsFormFillController.cpp
@@ -211,17 +211,17 @@ nsFormFillController::NodeWillBeDestroye
     mFocusedInput = nullptr;
   }
 }
 
 void
 nsFormFillController::MaybeRemoveMutationObserver(nsINode* aNode)
 {
   // Nodes being tracked in mPwmgrInputs will have their observers removed when
-  // they stop being tracked. 
+  // they stop being tracked.
   if (!mPwmgrInputs.Get(aNode)) {
     aNode->RemoveMutationObserver(this);
   }
 }
 
 ////////////////////////////////////////////////////////////////////////
 //// nsIFormFillController
 
@@ -895,17 +895,17 @@ nsFormFillController::RemoveForDocument(
 void
 nsFormFillController::MaybeStartControllingInput(nsIDOMHTMLInputElement* aInput)
 {
   nsCOMPtr<nsINode> inputNode = do_QueryInterface(aInput);
   if (!inputNode)
     return;
 
   nsCOMPtr<nsIFormControl> formControl = do_QueryInterface(aInput);
-  if (!formControl || !formControl->IsSingleLineTextControl(true))
+  if (!formControl || !formControl->IsSingleLineTextControl(false))
     return;
 
   bool isReadOnly = false;
   aInput->GetReadOnly(&isReadOnly);
   if (isReadOnly)
     return;
 
   bool autocomplete = nsContentUtils::IsAutocompleteEnabled(aInput);