Bug 1200472 - Include subdomain login fill suggestions in the context menu. r=sfoster
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Fri, 15 Nov 2019 01:03:48 +0000
changeset 502096 c72321ba48b8af4a8a4e36ee6212121429de6ce9
parent 502095 2e5826c18532792aa955c670e4a7d6f3919c3ff8
child 502097 cab5d681291405a636d403d0cb9bf5359f58afbb
push id114172
push userdluca@mozilla.com
push dateTue, 19 Nov 2019 11:31:10 +0000
treeherdermozilla-inbound@b5c5ba07d3db [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssfoster
bugs1200472
milestone72.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 1200472 - Include subdomain login fill suggestions in the context menu. r=sfoster Differential Revision: https://phabricator.services.mozilla.com/D51354
browser/base/content/nsContextMenu.js
browser/themes/linux/browser.css
browser/themes/osx/browser.css
browser/themes/windows/browser.css
toolkit/components/passwordmgr/LoginAutoCompleteResult.jsm
toolkit/components/passwordmgr/LoginManagerChild.jsm
toolkit/components/passwordmgr/LoginManagerContextMenu.jsm
toolkit/components/passwordmgr/test/LoginTestUtils.jsm
toolkit/components/passwordmgr/test/browser/browser_context_menu_generated_password.js
toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_searchAndDedupeLogins.js
toolkit/components/passwordmgr/test/unit/test_context_menu.js
--- a/browser/base/content/nsContextMenu.js
+++ b/browser/base/content/nsContextMenu.js
@@ -969,20 +969,24 @@ class nsContextMenu {
       );
     }
 
     if (!showFill || disableFill) {
       return;
     }
 
     let formOrigin = LoginHelper.getLoginOrigin(documentURI.spec);
+    let formActionOrigin = LoginHelper.getLoginOrigin(
+      loginFillInfo.formActionOrigin
+    );
     let fragment = nsContextMenu.LoginManagerContextMenu.addLoginsToMenu(
       this.targetIdentifier,
       this.browser,
-      formOrigin
+      formOrigin,
+      formActionOrigin
     );
     let isGeneratedPasswordEnabled =
       LoginHelper.generationAvailable && LoginHelper.generationEnabled;
     let canFillGeneratedPassword =
       this.onPassword &&
       isGeneratedPasswordEnabled &&
       Services.logins.getLoginSavingEnabled(formOrigin);
 
--- a/browser/themes/linux/browser.css
+++ b/browser/themes/linux/browser.css
@@ -457,16 +457,25 @@ notification[value="translation"] menuli
 %include ../shared/contextmenu.inc.css
 
 #context-navigation > .menuitem-iconic > .menu-iconic-left {
   /* override toolkit/themes/linux/global/menu.css */
   padding-inline-end: 0 !important;
   margin-inline-end: 0 !important;
 }
 
+#fill-login-popup > menucaption {
+  color: GrayText;
+  font-weight: normal;
+}
+
+#fill-login-popup > menucaption > .menu-iconic-left {
+  display: none;
+}
+
 .webextension-popup-browser,
 .webextension-popup-stack {
   border-radius: inherit;
 }
 
 /* We draw to titlebar when Gkt+ CSD is available */
 @media (-moz-gtk-csd-available) {
   /* Some Gtk+ themes use non-rectangular toplevel windows. To fully support
--- a/browser/themes/osx/browser.css
+++ b/browser/themes/osx/browser.css
@@ -813,16 +813,22 @@ menulist.translate-infobar-element > .me
 
 %include ../shared/contextmenu.inc.css
 
 #context-navigation > .menuitem-iconic {
   padding-left: 0;
   padding-right: 0;
 }
 
+#fill-login-popup > menucaption {
+  color: -moz-mac-menushadow;
+  font-weight: normal;
+  padding-inline-start: 11px;
+}
+
 .cui-widget-panelview[id^=PanelUI-webext-] {
   border-radius: 3.5px;
 }
 
 .webextension-popup-browser,
 .webextension-popup-stack {
   border-radius: inherit;
 }
--- a/browser/themes/windows/browser.css
+++ b/browser/themes/windows/browser.css
@@ -835,16 +835,29 @@ panel[touchmode] .PanelUI-subView #appMe
   padding-bottom: 4px;
 }
 
 #context-sep-navigation {
   margin-inline-start: -28px;
   margin-top: -4px;
 }
 
+#fill-login-popup > menucaption {
+  color: GrayText;
+}
+
+/* Match the treatment of menucaption used for <optgroup> */
+#fill-login-popup > menucaption > .menu-iconic-left {
+  display: none
+}
+
+#fill-login-popup > menucaption > .menu-iconic-text {
+  -moz-appearance: menuitemtext
+}
+
 %include browser-aero.css
 
 @media (-moz-os-version: windows-win7) {
   .cui-widget-panelview[id^=PanelUI-webext-] {
     border-radius: 4px;
   }
 }
 
--- a/toolkit/components/passwordmgr/LoginAutoCompleteResult.jsm
+++ b/toolkit/components/passwordmgr/LoginAutoCompleteResult.jsm
@@ -60,16 +60,18 @@ XPCOMUtils.defineLazyGetter(this, "passw
   return Services.strings.createBundle(
     "chrome://passwordmgr/locale/passwordmgr.properties"
   );
 });
 
 function loginSort(formHostPort, a, b) {
   let maybeHostPortA = LoginHelper.maybeGetHostPortForURL(a.origin);
   let maybeHostPortB = LoginHelper.maybeGetHostPortForURL(b.origin);
+
+  // Exact hostPort matches should appear first.
   if (formHostPort == maybeHostPortA && formHostPort != maybeHostPortB) {
     return -1;
   }
   if (formHostPort != maybeHostPortA && formHostPort == maybeHostPortB) {
     return 1;
   }
 
   if (a.httpRealm !== b.httpRealm) {
@@ -77,28 +79,18 @@ function loginSort(formHostPort, a, b) {
     if (b.httpRealm === null) {
       return 1;
     }
     if (a.httpRealm === null) {
       return -1;
     }
   }
 
-  let userA = a.username.toLowerCase();
-  let userB = b.username.toLowerCase();
-
-  if (userA < userB) {
-    return -1;
-  }
-
-  if (userA > userB) {
-    return 1;
-  }
-
-  return 0;
+  // Finally sort by username.
+  return a.username.localeCompare(b.username);
 }
 
 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);
--- a/toolkit/components/passwordmgr/LoginManagerChild.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerChild.jsm
@@ -2236,17 +2236,20 @@ this.LoginManagerChild = class LoginMana
     );
 
     // If we are not verifying a password field, we want
     // to use aField as the username field.
     if (aField.type != "password") {
       usernameField = aField;
     }
 
+    let form = LoginFormFactory.createFromField(aField);
+
     return {
+      formActionOrigin: LoginHelper.getFormActionOrigin(form),
       usernameField: {
         found: !!usernameField,
         disabled:
           usernameField && (usernameField.disabled || usernameField.readOnly),
       },
       passwordField: {
         found: !!newPasswordField,
         disabled:
--- a/toolkit/components/passwordmgr/LoginManagerContextMenu.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerContextMenu.jsm
@@ -35,59 +35,100 @@ this.LoginManagerContextMenu = {
    * @param {Object} inputElementIdentifier
    *        An identifier generated for the input element via ContentDOMReference.
    * @param {xul:browser} browser
    *        The browser for the document the context menu was open on.
    * @param {string} formOrigin
    *        The origin of the document that the context menu was activated from.
    *        This isn't the same as the browser's top-level document origin
    *        when subframes are involved.
+   * @param {string} formActionOrigin
+   *        The origin of the LoginForm's action.
    * @returns {DocumentFragment} a document fragment with all the login items.
    */
-  addLoginsToMenu(inputElementIdentifier, browser, formOrigin) {
-    let foundLogins = this._findLogins(formOrigin);
+  addLoginsToMenu(
+    inputElementIdentifier,
+    browser,
+    formOrigin,
+    formActionOrigin
+  ) {
+    let foundLogins = this._findLogins(formOrigin, formActionOrigin);
 
     if (!foundLogins.length) {
       return null;
     }
 
     let fragment = browser.ownerDocument.createDocumentFragment();
     let duplicateUsernames = this._findDuplicates(foundLogins);
+    // Default `lastDisplayOrigin` to the hostPort of the form so that we don't
+    // show a menucaption above logins that are direct matches for this document.
+    let lastDisplayOrigin = LoginHelper.maybeGetHostPortForURL(formOrigin);
+    let lastMenuCaption = null;
     for (let login of foundLogins) {
+      // Add a section header containing the displayOrigin above logins that
+      // aren't matches for the form's origin.
+      if (lastDisplayOrigin != login.displayOrigin) {
+        if (fragment.children.length) {
+          let menuSeparator = fragment.ownerDocument.createXULElement(
+            "menuseparator"
+          );
+          menuSeparator.className = "context-login-item";
+          fragment.appendChild(menuSeparator);
+        }
+
+        lastMenuCaption = fragment.ownerDocument.createXULElement(
+          "menucaption"
+        );
+        lastMenuCaption.setAttribute("role", "group");
+        lastMenuCaption.label = login.displayOrigin;
+        lastMenuCaption.className = "context-login-item";
+
+        fragment.appendChild(lastMenuCaption);
+      }
+
       let item = fragment.ownerDocument.createXULElement("menuitem");
 
       let username = login.username;
       // If login is empty or duplicated we want to append a modification date to it.
       if (!username || duplicateUsernames.has(username)) {
         if (!username) {
           username = this._getLocalizedString("noUsername");
         }
         let meta = login.QueryInterface(Ci.nsILoginMetaInfo);
         let time = this.dateAndTimeFormatter.format(
           new Date(meta.timePasswordChanged)
         );
         username = this._getLocalizedString("loginHostAge", [username, time]);
       }
+      item.id = "login-" + login.guid;
       item.setAttribute("label", username);
       item.setAttribute("class", "context-login-item");
+      if (lastMenuCaption) {
+        item.setAttribute("aria-level", "2");
+        lastMenuCaption.setAttribute(
+          "aria-owns",
+          lastMenuCaption.getAttribute("aria-owns") + item.id + " "
+        );
+      }
 
       // login is bound so we can keep the reference to each object.
       item.addEventListener(
         "command",
         function(login, event) {
           this._fillTargetField(
             login,
             inputElementIdentifier,
             browser,
             formOrigin
           );
         }.bind(this, login)
       );
 
       fragment.appendChild(item);
+      lastDisplayOrigin = login.displayOrigin;
     }
 
     return fragment;
   },
 
   /**
    * Undoes the work of addLoginsToMenu for the same menu.
    *
@@ -135,61 +176,62 @@ this.LoginManagerContextMenu = {
       password,
       origin,
       originMatches,
       inputElementIdentifier,
       recipes,
     });
   },
 
+  loginSort(formHostPort, a, b) {
+    let maybeHostPortA = LoginHelper.maybeGetHostPortForURL(a.origin);
+    let maybeHostPortB = LoginHelper.maybeGetHostPortForURL(b.origin);
+
+    // Exact hostPort matches should appear first.
+    if (formHostPort == maybeHostPortA && formHostPort != maybeHostPortB) {
+      return -1;
+    }
+    if (formHostPort != maybeHostPortA && formHostPort == maybeHostPortB) {
+      return 1;
+    }
+
+    // Next sort by displayOrigin (which contains the httpRealm)
+    if (a.displayOrigin !== b.displayOrigin) {
+      return a.displayOrigin.localeCompare(b.displayOrigin);
+    }
+
+    // Finally sort by username within the displayOrigin.
+    return a.username.localeCompare(b.username);
+  },
+
   /**
-   * Find logins for the specified origin..
+   * Find logins for the specified origin.
    *
    * @param {string} formOrigin
    *        Origin of the logins we want to find that has be sanitized by `getLoginOrigin`.
    *        This isn't the same as the browser's top-level document URI
    *        when subframes are involved.
+   * @param {string} formActionOrigin
    *
    * @returns {nsILoginInfo[]} a login list
    */
-  _findLogins(formOrigin) {
+  _findLogins(formOrigin, formActionOrigin) {
     let searchParams = {
-      origin: formOrigin,
-      schemeUpgrades: LoginHelper.schemeUpgrades,
+      acceptDifferentSubdomains: LoginHelper.includeOtherSubdomainsInLookup,
+      formActionOrigin,
+      ignoreActionAndRealm: true,
     };
-    let logins = LoginHelper.searchLoginsWithObject(searchParams);
-    let resolveBy = ["scheme", "timePasswordChanged"];
-    logins = LoginHelper.dedupeLogins(
-      logins,
-      ["username", "password"],
-      resolveBy,
-      formOrigin
+
+    let logins = LoginManagerParent.searchAndDedupeLogins(
+      formOrigin,
+      searchParams
     );
 
-    // Sort logins in alphabetical order and by date.
-    logins.sort((loginA, loginB) => {
-      // Sort alphabetically
-      let result = loginA.username.localeCompare(loginB.username);
-      if (result) {
-        // Forces empty logins to be at the end
-        if (!loginA.username) {
-          return 1;
-        }
-        if (!loginB.username) {
-          return -1;
-        }
-        return result;
-      }
-
-      // Same username logins are sorted by last change date
-      let metaA = loginA.QueryInterface(Ci.nsILoginMetaInfo);
-      let metaB = loginB.QueryInterface(Ci.nsILoginMetaInfo);
-      return metaB.timePasswordChanged - metaA.timePasswordChanged;
-    });
-
+    let formHostPort = LoginHelper.maybeGetHostPortForURL(formOrigin);
+    logins.sort(this.loginSort.bind(null, formHostPort));
     return logins;
   },
 
   /**
    * Find duplicate usernames in a login list.
    *
    * @param {nsILoginInfo[]} loginList
    *        A list of logins we want to look for duplicate usernames.
--- a/toolkit/components/passwordmgr/test/LoginTestUtils.jsm
+++ b/toolkit/components/passwordmgr/test/LoginTestUtils.jsm
@@ -210,17 +210,17 @@ this.LoginTestUtils.testData = {
         "https://www.example.com",
         null,
         "the username",
         "the password for https",
         "form_field_username",
         "form_field_password"
       ),
 
-      // Subdomains are treated as completely different sites.
+      // Subdomains can be treated as completely different sites depending on the UI invoked.
       new LoginInfo(
         "https://example.com",
         "https://example.com",
         null,
         "the username",
         "the password for example.com",
         "form_field_username",
         "form_field_password"
--- a/toolkit/components/passwordmgr/test/browser/browser_context_menu_generated_password.js
+++ b/toolkit/components/passwordmgr/test/browser/browser_context_menu_generated_password.js
@@ -294,17 +294,17 @@ add_task(async function fill_generated_p
           );
           await ContentTaskUtils.waitForEvent(passwordInput, "input");
         }
       );
 
       let popupMenu = document.getElementById("fill-login-popup");
       let firstLoginItem = popupMenu.getElementsByClassName(
         "context-login-item"
-      )[0];
+      )[1];
       firstLoginItem.doCommand();
 
       await passwordChangedPromise;
 
       let contextMenu = document.getElementById("contentAreaContextMenu");
       contextMenu.hidePopup();
 
       // Blur the field to trigger a 'change' event.
--- a/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_searchAndDedupeLogins.js
+++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_searchAndDedupeLogins.js
@@ -169,17 +169,17 @@ add_task(async function test_searchAndDe
 
     let guids = await Services.logins.addLogins(tc.logins);
     Assert.strictEqual(
       guids.length,
       tc.logins.length,
       "Check length of added logins"
     );
 
-    let actual = new LMP()._searchAndDedupeLogins(tc.formActionOrigin, {
+    let actual = LMP.searchAndDedupeLogins(tc.formActionOrigin, {
       formActionOrigin: tc.formActionOrigin,
       looseActionOriginMatch: true,
       acceptDifferentSubdomains: true,
     });
     info(`actual:\n ${JSON.stringify(actual, null, 2)}`);
     info(`expected:\n ${JSON.stringify(tc.expected, null, 2)}`);
     Assert.strictEqual(
       actual.length,
--- a/toolkit/components/passwordmgr/test/unit/test_context_menu.js
+++ b/toolkit/components/passwordmgr/test/unit/test_context_menu.js
@@ -3,232 +3,472 @@
  */
 
 "use strict";
 
 const { LoginManagerContextMenu } = ChromeUtils.import(
   "resource://gre/modules/LoginManagerContextMenu.jsm"
 );
 
+const dateAndTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+  dateStyle: "medium",
+});
+
+const ORIGIN_HTTP_EXAMPLE_ORG = "http://example.org";
+const ORIGIN_HTTPS_EXAMPLE_ORG = "https://example.org";
+const ORIGIN_HTTPS_EXAMPLE_ORG_8080 = "https://example.org:8080";
+const ORIGIN_HTTPS_SUB_EXAMPLE_ORG = "https://sub.example.org";
+
+const FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1 = formLogin({
+  formActionOrigin: ORIGIN_HTTPS_EXAMPLE_ORG,
+  guid: "FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1",
+  origin: ORIGIN_HTTPS_EXAMPLE_ORG,
+});
+
+// HTTP version of the above
+const FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1 = formLogin({
+  formActionOrigin: ORIGIN_HTTP_EXAMPLE_ORG,
+  guid: "FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1",
+  origin: ORIGIN_HTTP_EXAMPLE_ORG,
+});
+
+// Same as above but with a different password
+const FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2 = formLogin({
+  formActionOrigin: ORIGIN_HTTP_EXAMPLE_ORG,
+  guid: "FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2",
+  origin: ORIGIN_HTTP_EXAMPLE_ORG,
+  password: "pass2",
+});
+
+// Non-default port
+
+const FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2 = formLogin({
+  formActionOrigin: ORIGIN_HTTPS_EXAMPLE_ORG_8080,
+  guid: "FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2",
+  origin: ORIGIN_HTTPS_EXAMPLE_ORG_8080,
+  password: "pass2",
+});
+
+// Subdomain
+
+const FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P1 = formLogin({
+  formActionOrigin: ORIGIN_HTTPS_SUB_EXAMPLE_ORG,
+  guid: "FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P1",
+  origin: ORIGIN_HTTPS_SUB_EXAMPLE_ORG,
+});
+
+const FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P2 = formLogin({
+  formActionOrigin: ORIGIN_HTTPS_SUB_EXAMPLE_ORG,
+  guid: "FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P2",
+  origin: ORIGIN_HTTPS_SUB_EXAMPLE_ORG,
+  password: "pass2",
+});
+
+// HTTP Auth.
+
+const HTTP_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1 = authLogin({
+  guid: "FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1",
+  origin: ORIGIN_HTTPS_EXAMPLE_ORG,
+});
+
 XPCOMUtils.defineLazyGetter(this, "_stringBundle", function() {
   return Services.strings.createBundle(
     "chrome://passwordmgr/locale/passwordmgr.properties"
   );
 });
 
 /**
  * Prepare data for the following tests.
  */
 add_task(async function test_initialize() {
-  for (let login of loginList()) {
-    Services.logins.addLogin(login);
-  }
+  Services.prefs.setBoolPref("signon.schemeUpgrades", true);
+  Services.prefs.setBoolPref("signon.includeOtherSubdomainsInLookup", true);
+});
+
+add_task(async function test_sameOriginOnlyHTTPS() {
+  await runTestcase({
+    formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin,
+    savedLogins: [FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1],
+    expectedItems: [
+      {
+        login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      },
+    ],
+  });
+});
+
+add_task(async function test_sameOriginOnlyHTTPS_noUsername() {
+  let loginWithoutUsername = FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.clone();
+  loginWithoutUsername.QueryInterface(Ci.nsILoginMetaInfo).guid = "no-username";
+  loginWithoutUsername.username = "";
+  await runTestcase({
+    formOrigin: loginWithoutUsername.origin,
+    savedLogins: [loginWithoutUsername],
+    expectedItems: [
+      {
+        login: loginWithoutUsername,
+        time: true,
+      },
+    ],
+  });
+});
+
+add_task(async function test_sameOriginOnlyHTTP() {
+  await runTestcase({
+    formOrigin: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1.origin,
+    savedLogins: [FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1],
+    expectedItems: [
+      {
+        login: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1,
+      },
+    ],
+  });
+});
+
+// Scheme upgrade/downgrade tasks
+
+add_task(async function test_sameOriginDedupeSchemeUpgrade() {
+  await runTestcase({
+    formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin,
+    savedLogins: [
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1,
+    ],
+    expectedItems: [
+      {
+        login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      },
+    ],
+  });
+});
+
+add_task(async function test_sameOriginSchemeDowngrade() {
+  // Should have no https: when formOrigin is https:
+  await runTestcase({
+    formOrigin: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1.origin,
+    savedLogins: [
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1,
+    ],
+    expectedItems: [
+      {
+        login: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1,
+      },
+    ],
+  });
+});
+
+add_task(async function test_sameOriginShadowedSchemeUpgrade() {
+  await runTestcase({
+    formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin,
+    savedLogins: [
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2, // Different password
+    ],
+    expectedItems: [
+      {
+        login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      },
+    ],
+  });
+});
+
+add_task(async function test_sameOriginShadowedSchemeDowngrade() {
+  // Should have no https: when formOrigin is https:
+  await runTestcase({
+    formOrigin: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1.origin,
+    savedLogins: [
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2, // Different password
+    ],
+    expectedItems: [
+      {
+        login: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2,
+      },
+    ],
+  });
+});
+
+// Non-default port tasks
+
+add_task(async function test_sameDomainDifferentPort_onDefault() {
+  await runTestcase({
+    formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin,
+    savedLogins: [
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2,
+    ],
+    expectedItems: [
+      {
+        login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+        time: true,
+      },
+      "--", // separator
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2.displayOrigin, // group heading
+      {
+        login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2,
+        time: true,
+      },
+    ],
+  });
 });
 
+add_task(async function test_sameDomainDifferentPort_onNonDefault() {
+  await runTestcase({
+    // Swap the formOrigin compared to above
+    formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2.origin,
+    savedLogins: [
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2,
+    ],
+    expectedItems: [
+      {
+        login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2,
+        time: true,
+      },
+      "--", // separator
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.displayOrigin, // group heading
+      {
+        login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+        time: true,
+      },
+    ],
+  });
+});
+
+// Subdomain tasks
+
+add_task(async function test_onlySubdomainOnBaseDomain() {
+  await runTestcase({
+    formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin,
+    savedLogins: [FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P1],
+    expectedItems: [
+      // No separator
+      FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P1.displayOrigin,
+      {
+        login: FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P1,
+      },
+    ],
+  });
+});
+
+add_task(async function test_subdomainDedupeOnBaseDomain() {
+  await runTestcase({
+    formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin,
+    savedLogins: [
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P1,
+    ],
+    expectedItems: [
+      {
+        login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      },
+    ],
+  });
+});
+
+add_task(async function test_subdomainDedupeOnSubDomain() {
+  await runTestcase({
+    formOrigin: FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P1.origin,
+    savedLogins: [
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P1,
+    ],
+    expectedItems: [
+      {
+        login: FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P1,
+      },
+    ],
+  });
+});
+
+add_task(async function test_subdomainIncludedOnBaseDomain() {
+  await runTestcase({
+    formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin,
+    savedLogins: [
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P2,
+    ],
+    expectedItems: [
+      {
+        login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+        time: true,
+      },
+      "--", // separator
+      FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P2.displayOrigin, // group heading
+      {
+        login: FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P2,
+        time: true,
+      },
+    ],
+  });
+});
+
+add_task(async function test_subdomainIncludedOnSubDomain() {
+  await runTestcase({
+    formOrigin: FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P2.origin,
+    savedLogins: [
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P2,
+    ],
+    expectedItems: [
+      {
+        login: FORM_LOGIN_HTTPS_SUB_EXAMPLE_ORG_U1_P2,
+        time: true,
+      },
+      "--", // separator
+      FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.displayOrigin, // group heading
+      {
+        login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+        time: true,
+      },
+    ],
+  });
+});
+
+// HTTP auth. suggestions
+
+add_task(async function test_sameOriginOnlyHTTPAuth() {
+  await runTestcase({
+    formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin,
+    savedLogins: [HTTP_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1],
+    expectedItems: [
+      // No separator
+      HTTP_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.displayOrigin, // group heading
+      {
+        login: HTTP_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+      },
+    ],
+  });
+});
+
+// Helpers
+
+function formLogin(modifications = {}) {
+  let mods = Object.assign(
+    {},
+    {
+      timePasswordChanged: 1573821296000,
+    },
+    modifications
+  );
+  return TestData.formLogin(mods);
+}
+
+function authLogin(modifications = {}) {
+  let mods = Object.assign(
+    {},
+    {
+      timePasswordChanged: 1573821296000,
+    },
+    modifications
+  );
+  return TestData.authLogin(mods);
+}
+
 /**
  * Tests if the LoginManagerContextMenu returns the correct login items.
  */
-add_task(async function test_contextMenuAddAndRemoveLogins() {
+async function runTestcase({ formOrigin, savedLogins, expectedItems }) {
   const DOCUMENT_CONTENT = "<form><input id='pw' type=password></form>";
-  const INPUT_QUERY = "input[type='password']";
-
-  let testOrigins = [
-    "http://www.example.com",
-    "http://www2.example.com",
-    "http://www3.example.com",
-    "http://empty.example.com",
-  ];
-
-  for (let origin of testOrigins) {
-    info("test for origin: " + origin);
-    // Get expected logins for this test.
-    let logins = getExpectedLogins(origin);
 
-    // Create the logins menuitems fragment.
-    let { fragment, document } = createLoginsFragment(
-      origin,
-      DOCUMENT_CONTENT,
-      INPUT_QUERY
-    );
-
-    if (!logins.length) {
-      Assert.ok(fragment === null, "Null returned. No logins where found.");
-      continue;
-    }
-    let items = [...fragment.querySelectorAll("menuitem")];
-
-    // Check if the items are those expected to be listed.
-    Assert.ok(checkLoginItems(logins, items), "All expected logins found.");
-    document.body.appendChild(fragment);
-
-    // Try to clear the fragment.
-    LoginManagerContextMenu.clearLoginsFromMenu(document);
-    Assert.equal(
-      fragment.querySelectorAll("menuitem").length,
-      0,
-      "All items correctly cleared."
-    );
+  for (let login of savedLogins) {
+    Services.logins.addLogin(login);
   }
 
+  // Create the logins menuitems fragment.
+  let { fragment, document } = createLoginsFragment(
+    formOrigin,
+    DOCUMENT_CONTENT
+  );
+
+  if (!expectedItems.length) {
+    Assert.ok(fragment === null, "Null returned. No logins were found.");
+    return;
+  }
+  let actualItems = [...fragment.children];
+
+  // Check if the items are those expected to be listed.
+  checkLoginItems(actualItems, expectedItems);
+
+  document.body.appendChild(fragment);
+
+  // Try to clear the fragment.
+  LoginManagerContextMenu.clearLoginsFromMenu(document);
+  Assert.equal(
+    document.querySelectorAll("menuitem, menuseparator, menucaption").length,
+    0,
+    "All items correctly cleared."
+  );
+
   Services.logins.removeAllLogins();
-});
+}
 
 /**
  * Create a fragment with a menuitem for each login.
  */
-function createLoginsFragment(url, content, elementQuery) {
-  const CHROME_URL = "chrome://mock-chrome";
+function createLoginsFragment(url, content) {
+  const CHROME_URL = "chrome://mock-chrome/content/";
 
   // Create a mock document.
   let document = MockDocument.createTestDocument(CHROME_URL, content);
-  let inputElement = document.querySelector(elementQuery);
-  MockDocument.mockOwnerDocumentProperty(inputElement, document, url);
 
   // We also need a simple mock Browser object for this test.
   document.createXULElement = document.createElement.bind(document);
   let browser = {
     ownerDocument: document,
   };
 
   let formOrigin = LoginHelper.getLoginOrigin(url);
   return {
     document,
     fragment: LoginManagerContextMenu.addLoginsToMenu(
-      inputElement,
+      null,
       browser,
       formOrigin
     ),
   };
 }
 
-/**
- * Check if every login have it's corresponding menuitem.
- * Duplicates and empty usernames have a date appended.
- */
-function checkLoginItems(logins, items) {
-  function findDuplicates(unfilteredLoginList) {
-    let seen = new Set();
-    let duplicates = new Set();
-    for (let login of unfilteredLoginList) {
-      if (seen.has(login.username)) {
-        duplicates.add(login.username);
-      }
-      seen.add(login.username);
+function checkLoginItems(actualItems, expectedDetails) {
+  for (let [i, expectedDetail] of expectedDetails.entries()) {
+    let actualElement = actualItems[i];
+
+    // Separator
+    if (expectedDetail == "--") {
+      Assert.equal(actualElement.localName, "menuseparator", "Check localName");
+      continue;
     }
-    return duplicates;
-  }
-  let duplicates = findDuplicates(logins);
 
-  let dateAndTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
-    dateStyle: "medium",
-  });
-  for (let login of logins) {
-    if (login.username && !duplicates.has(login.username)) {
-      // If login is not duplicate and we can't find an item for it, fail.
-      if (!items.find(item => item.label == login.username)) {
-        return false;
-      }
+    // Section heading
+    if (typeof expectedDetail == "string") {
+      Assert.equal(actualElement.localName, "menucaption", "Check localName");
       continue;
     }
 
-    let meta = login.QueryInterface(Ci.nsILoginMetaInfo);
-    let time = dateAndTimeFormatter.format(new Date(meta.timePasswordChanged));
-    // If login is duplicate, check if we have a login item with appended date.
-    if (
-      login.username &&
-      !items.find(item => item.label == login.username + " (" + time + ")")
-    ) {
-      return false;
-    }
-    // If login is empty, check if we have a login item with appended date.
-    if (
-      !login.username &&
-      !items.find(
-        item =>
-          item.label ==
-          _stringBundle.GetStringFromName("noUsername") + " (" + time + ")"
-      )
-    ) {
-      return false;
-    }
-  }
-  return true;
-}
-
-/**
- * Gets the list of expected logins for an origin.
- */
-function getExpectedLogins(origin) {
-  return Services.logins
-    .getAllLogins()
-    .filter(entry => entry.origin === origin);
-}
-
-function loginList() {
-  return [
-    new LoginInfo(
-      "http://www.example.com",
-      "http://www.example.com",
-      null,
-      "username1",
-      "password",
-      "form_field_username",
-      "form_field_password"
-    ),
+    Assert.equal(actualElement.localName, "menuitem", "Check localName");
+    Assert.equal(
+      actualElement.id,
+      "login-" + expectedDetail.login.guid,
+      `Check id ${i}`
+    );
 
-    new LoginInfo(
-      "http://www.example.com",
-      "http://www.example.com",
-      null,
-      "username2",
-      "password",
-      "form_field_username",
-      "form_field_password"
-    ),
+    let expectedLabel = expectedDetail.login.username;
+    if (!expectedLabel) {
+      expectedLabel += _stringBundle.GetStringFromName("noUsername");
+    }
+    if (expectedDetail.time) {
+      expectedLabel +=
+        " (" +
+        dateAndTimeFormatter.format(
+          new Date(expectedDetail.login.timePasswordChanged)
+        ) +
+        ")";
+    }
+    Assert.equal(actualElement.label, expectedLabel, `Check label ${i}`);
+  }
 
-    new LoginInfo(
-      "http://www2.example.com",
-      "http://www.example.com",
-      null,
-      "username",
-      "password",
-      "form_field_username",
-      "form_field_password"
-    ),
-    new LoginInfo(
-      "http://www2.example.com",
-      "http://www2.example.com",
-      null,
-      "username",
-      "password2",
-      "form_field_username",
-      "form_field_password"
-    ),
-    new LoginInfo(
-      "http://www2.example.com",
-      "http://www2.example.com",
-      null,
-      "username2",
-      "password2",
-      "form_field_username",
-      "form_field_password"
-    ),
-
-    new LoginInfo(
-      "http://www3.example.com",
-      "http://www.example.com",
-      null,
-      "",
-      "password",
-      "form_field_username",
-      "form_field_password"
-    ),
-    new LoginInfo(
-      "http://www3.example.com",
-      "http://www3.example.com",
-      null,
-      "",
-      "password2",
-      "form_field_username",
-      "form_field_password"
-    ),
-  ];
+  Assert.equal(
+    actualItems.length,
+    expectedDetails.length,
+    "Should have the correct number of menu items"
+  );
 }